From 8aa9fd4d0f4950c929d410672becd6085c9e064e Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 28 Dec 2025 14:51:18 +0100 Subject: [PATCH] fix: improve assignment capture and restore flows --- .../BackupItemsRelationManager.php | 19 +- app/Filament/Resources/RestoreRunResource.php | 5 + app/Services/AssignmentBackupService.php | 49 +++- app/Services/AssignmentRestoreService.php | 73 +++++- app/Services/Graph/AssignmentFetcher.php | 213 ++++++++++++++---- app/Services/Intune/BackupService.php | 8 + .../Intune/PolicyCaptureOrchestrator.php | 78 +++++-- app/Services/Intune/RestoreService.php | 44 +++- app/Services/Intune/VersionService.php | 60 ++++- config/graph_contracts.php | 11 + ...olicy-version-assignments-widget.blade.php | 26 ++- specs/007-device-config-compliance/tasks.md | 6 +- tests/Feature/Filament/BackupCreationTest.php | 67 +++++- .../Feature/Filament/RestoreExecutionTest.php | 4 + .../Filament/RestoreItemSelectionTest.php | 21 ++ .../Filament/SettingsCatalogRestoreTest.php | 2 + .../PolicyVersionViewAssignmentsTest.php | 20 ++ .../VersionCaptureWithAssignmentsTest.php | 68 +++++- tests/Unit/AssignmentBackupServiceTest.php | 93 ++++++++ tests/Unit/AssignmentFetcherTest.php | 29 ++- tests/Unit/AssignmentRestoreServiceTest.php | 202 +++++++++++++++++ 21 files changed, 987 insertions(+), 111 deletions(-) create mode 100644 tests/Unit/AssignmentBackupServiceTest.php create mode 100644 tests/Unit/AssignmentRestoreServiceTest.php diff --git a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php index 6c53439..dd20cf2 100644 --- a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php +++ b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php @@ -52,16 +52,26 @@ public function table(Table $table): Table ->label('Assignments') ->badge() ->color('info') - ->getStateUsing(function (BackupItem $record): int { - $assignments = $record->policyVersion?->assignments ?? $record->assignments ?? []; + ->getStateUsing(function (BackupItem $record): string { + $assignments = $record->policyVersion?->assignments ?? $record->assignments; - return is_array($assignments) ? count($assignments) : 0; + if (is_array($assignments)) { + return (string) count($assignments); + } + + $assignmentsFetched = $record->policyVersion?->metadata['assignments_fetched'] + ?? $record->metadata['assignments_fetched'] + ?? false; + + return $assignmentsFetched ? '0' : '—'; }), Tables\Columns\TextColumn::make('scope_tags') ->label('Scope Tags') ->default('—') ->getStateUsing(function (BackupItem $record): array { - $tags = $record->policyVersion?->scope_tags['names'] ?? []; + $tags = $record->policyVersion?->scope_tags['names'] + ?? $record->metadata['scope_tag_names'] + ?? []; return is_array($tags) ? $tags : []; }) @@ -100,6 +110,7 @@ public function table(Table $table): Table return Policy::query() ->where('tenant_id', $tenantId) + ->whereNull('ignored_at') ->where('last_synced_at', '>', now()->subDays(7)) // Hide deleted policies (Feature 005 workaround) ->when($existing, fn (Builder $query) => $query->whereNotIn('id', $existing)) ->orderBy('display_name') diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 50087c9..bbc4d25 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -543,6 +543,11 @@ private static function restoreItemOptionData(?int $backupSetId): array $items = BackupItem::query() ->where('backup_set_id', $backupSetId) ->whereHas('backupSet', fn ($query) => $query->where('tenant_id', $tenant->getKey())) + ->where(function ($query) { + $query->whereNull('policy_id') + ->orWhereDoesntHave('policy') + ->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at')); + }) ->with('policy:id,display_name') ->get() ->sortBy(function (BackupItem $item) { diff --git a/app/Services/AssignmentBackupService.php b/app/Services/AssignmentBackupService.php index 0c12fa3..9c3e941 100644 --- a/app/Services/AssignmentBackupService.php +++ b/app/Services/AssignmentBackupService.php @@ -93,12 +93,7 @@ public function enrichWithAssignments( ->contains(fn (array $group) => $group['orphaned'] ?? false); } - $filterIds = collect($assignments) - ->pluck('target.deviceAndAppManagementAssignmentFilterId') - ->filter() - ->unique() - ->values() - ->all(); + $filterIds = $this->extractAssignmentFilterIds($assignments); $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); $filterNames = collect($filters) @@ -183,9 +178,21 @@ private function enrichAssignments(array $assignments, array $groups, array $fil $target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false; } - $filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; - if ($filterId && isset($filterNames[$filterId])) { - $target['assignment_filter_name'] = $filterNames[$filterId]; + $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] + ?? ($target['deviceAndAppManagementAssignmentFilterId'] ?? null); + $filterType = $assignment['deviceAndAppManagementAssignmentFilterType'] + ?? ($target['deviceAndAppManagementAssignmentFilterType'] ?? null); + + if ($filterId) { + $target['deviceAndAppManagementAssignmentFilterId'] = $target['deviceAndAppManagementAssignmentFilterId'] ?? $filterId; + + if ($filterType) { + $target['deviceAndAppManagementAssignmentFilterType'] = $target['deviceAndAppManagementAssignmentFilterType'] ?? $filterType; + } + + if (isset($filterNames[$filterId])) { + $target['assignment_filter_name'] = $filterNames[$filterId]; + } } $assignment['target'] = $target; @@ -193,4 +200,28 @@ private function enrichAssignments(array $assignments, array $groups, array $fil return $assignment; }, $assignments); } + + /** + * @param array> $assignments + * @return array + */ + private function extractAssignmentFilterIds(array $assignments): array + { + $filterIds = []; + + foreach ($assignments as $assignment) { + if (! is_array($assignment)) { + continue; + } + + $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] + ?? ($assignment['target']['deviceAndAppManagementAssignmentFilterId'] ?? null); + + if (is_string($filterId) && $filterId !== '') { + $filterIds[] = $filterId; + } + } + + return array_values(array_unique($filterIds)); + } } diff --git a/app/Services/AssignmentRestoreService.php b/app/Services/AssignmentRestoreService.php index 6d32690..980749c 100644 --- a/app/Services/AssignmentRestoreService.php +++ b/app/Services/AssignmentRestoreService.php @@ -4,6 +4,7 @@ use App\Models\RestoreRun; use App\Models\Tenant; +use App\Services\Graph\AssignmentFilterResolver; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphContractRegistry; use App\Services\Graph\GraphLogger; @@ -19,6 +20,7 @@ public function __construct( private readonly GraphContractRegistry $contracts, private readonly GraphLogger $graphLogger, private readonly AuditLogger $auditLogger, + private readonly AssignmentFilterResolver $assignmentFilterResolver, ) {} /** @@ -56,6 +58,11 @@ public function restore( $createPath = $this->resolvePath($contract['assignments_create_path'] ?? null, $policyId); $createMethod = strtoupper((string) ($contract['assignments_create_method'] ?? 'POST')); $usesAssignAction = is_string($createPath) && str_ends_with($createPath, '/assign'); + $assignmentsPayloadKey = $contract['assignments_payload_key'] ?? 'assignments'; + + if (! is_string($assignmentsPayloadKey) || $assignmentsPayloadKey === '') { + $assignmentsPayloadKey = 'assignments'; + } $listPath = $this->resolvePath($contract['assignments_list_path'] ?? null, $policyId); $deletePathTemplate = $contract['assignments_delete_path'] ?? null; @@ -84,13 +91,39 @@ public function restore( $assignmentFilterMapping = $foundationMapping['assignmentFilter'] ?? []; + if ($assignmentFilterMapping === []) { + $filterIds = $this->extractAssignmentFilterIds($assignments); + + if ($filterIds !== []) { + $resolvedFilters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); + + foreach ($resolvedFilters as $filter) { + $filterId = $filter['id'] ?? null; + + if (is_string($filterId) && $filterId !== '') { + $assignmentFilterMapping[$filterId] = $filterId; + } + } + } + } + foreach ($assignments as $assignment) { if (! is_array($assignment)) { continue; } $target = $assignment['target'] ?? []; - $filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; + $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] + ?? ($target['deviceAndAppManagementAssignmentFilterId'] ?? null); + $filterLocation = array_key_exists('deviceAndAppManagementAssignmentFilterId', $assignment) ? 'root' : 'target'; + + if (! is_string($filterId) && ! is_int($filterId)) { + $filterId = null; + } + + if (is_string($filterId) && $filterId === '') { + $filterId = null; + } if ($filterId !== null) { if ($assignmentFilterMapping === []) { @@ -142,8 +175,12 @@ public function restore( continue; } - $target['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId; - $assignment['target'] = $target; + if ($filterLocation === 'root') { + $assignment['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId; + } else { + $target['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId; + $assignment['target'] = $target; + } } $groupId = $assignment['target']['groupId'] ?? null; @@ -196,7 +233,7 @@ public function restore( ]); $assignResponse = $this->graphClient->request($createMethod, $createPath, [ - 'json' => ['assignments' => $preparedAssignments], + 'json' => [$assignmentsPayloadKey => $preparedAssignments], ] + $graphOptions); $this->graphLogger->logResponse('restore_assignments_assign', $assignResponse, $context + [ @@ -413,6 +450,34 @@ private function resolvePath(?string $template, string $policyId, ?string $assig return $path; } + /** + * @param array> $assignments + * @return array + */ + private function extractAssignmentFilterIds(array $assignments): array + { + $filterIds = []; + + foreach ($assignments as $assignment) { + if (! is_array($assignment)) { + continue; + } + + $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] + ?? ($assignment['target']['deviceAndAppManagementAssignmentFilterId'] ?? null); + + if (is_string($filterId) || is_int($filterId)) { + $filterId = (string) $filterId; + + if ($filterId !== '') { + $filterIds[] = $filterId; + } + } + } + + return array_values(array_unique($filterIds)); + } + private function applyGroupMapping(array $assignment, ?string $mappedGroupId): array { if (! $mappedGroupId) { diff --git a/app/Services/Graph/AssignmentFetcher.php b/app/Services/Graph/AssignmentFetcher.php index 415881d..f7efa2d 100644 --- a/app/Services/Graph/AssignmentFetcher.php +++ b/app/Services/Graph/AssignmentFetcher.php @@ -19,84 +19,138 @@ public function __construct( * * @return array Returns assignment array or empty array on failure */ - public function fetch(string $policyType, string $tenantId, string $policyId, array $options = []): array - { + public function fetch( + string $policyType, + string $tenantId, + string $policyId, + array $options = [], + bool $throwOnFailure = false + ): array { + $contract = $this->contracts->get($policyType); + $listPathTemplate = $contract['assignments_list_path'] ?? null; + $resource = $contract['resource'] ?? null; + $requestOptions = array_merge($options, ['tenant' => $tenantId]); + $context = [ + 'tenant_id' => $tenantId, + 'policy_type' => $policyType, + 'policy_id' => $policyId, + ]; + + $primaryException = null; + $assignments = []; + + // Try primary endpoint try { - $contract = $this->contracts->get($policyType); - $listPathTemplate = $contract['assignments_list_path'] ?? null; - $resource = $contract['resource'] ?? null; - $requestOptions = array_merge($options, ['tenant' => $tenantId]); + $assignments = $this->fetchPrimary( + $listPathTemplate, + $policyId, + $requestOptions, + $context, + $throwOnFailure + ); + } catch (GraphException $e) { + $primaryException = $e; + } - // Try primary endpoint - $assignments = $this->fetchPrimary($listPathTemplate, $policyId, $requestOptions); + if (! empty($assignments)) { + Log::debug('Fetched assignments via primary endpoint', [ + 'tenant_id' => $tenantId, + 'policy_type' => $policyType, + 'policy_id' => $policyId, + 'count' => count($assignments), + ]); - if (! empty($assignments)) { - Log::debug('Fetched assignments via primary endpoint', [ - 'tenant_id' => $tenantId, - 'policy_type' => $policyType, - 'policy_id' => $policyId, - 'count' => count($assignments), - ]); + return $assignments; + } - return $assignments; - } + // Try fallback with $expand + Log::debug('Primary endpoint returned empty, trying fallback', [ + 'tenant_id' => $tenantId, + 'policy_type' => $policyType, + 'policy_id' => $policyId, + ]); - // Try fallback with $expand - Log::debug('Primary endpoint returned empty, trying fallback', [ + if (! is_string($resource) || $resource === '') { + Log::debug('Assignments resource not configured for policy type', [ 'tenant_id' => $tenantId, 'policy_type' => $policyType, 'policy_id' => $policyId, ]); - if (! is_string($resource) || $resource === '') { - Log::debug('Assignments resource not configured for policy type', [ + if ($throwOnFailure && $primaryException) { + Log::warning('Failed to fetch assignments', [ 'tenant_id' => $tenantId, 'policy_type' => $policyType, 'policy_id' => $policyId, + 'error' => $primaryException->getMessage(), + 'context' => $primaryException->context, ]); - return []; + throw $primaryException; } - $assignments = $this->fetchWithExpand($resource, $policyId, $requestOptions); - - if (! empty($assignments)) { - Log::debug('Fetched assignments via fallback endpoint', [ - 'tenant_id' => $tenantId, - 'policy_type' => $policyType, - 'policy_id' => $policyId, - 'count' => count($assignments), - ]); - - return $assignments; - } - - // Both methods returned empty - Log::debug('No assignments found for policy', [ - 'tenant_id' => $tenantId, - 'policy_type' => $policyType, - 'policy_id' => $policyId, - ]); - return []; + } + + $fallbackException = null; + + try { + $assignments = $this->fetchWithExpand( + $resource, + $policyId, + $requestOptions, + $context, + $throwOnFailure + ); } catch (GraphException $e) { + $fallbackException = $e; + } + + if (! empty($assignments)) { + Log::debug('Fetched assignments via fallback endpoint', [ + 'tenant_id' => $tenantId, + 'policy_type' => $policyType, + 'policy_id' => $policyId, + 'count' => count($assignments), + ]); + + return $assignments; + } + + // Both methods returned empty + Log::debug('No assignments found for policy', [ + 'tenant_id' => $tenantId, + 'policy_type' => $policyType, + 'policy_id' => $policyId, + ]); + + if ($throwOnFailure && ($fallbackException || $primaryException)) { + $exception = $fallbackException ?? $primaryException; + Log::warning('Failed to fetch assignments', [ 'tenant_id' => $tenantId, 'policy_type' => $policyType, 'policy_id' => $policyId, - 'error' => $e->getMessage(), - 'context' => $e->context, + 'error' => $exception->getMessage(), + 'context' => $exception->context, ]); - return []; + throw $exception; } + + return []; } /** * Fetch assignments using primary endpoint. */ - private function fetchPrimary(?string $listPathTemplate, string $policyId, array $options): array - { + private function fetchPrimary( + ?string $listPathTemplate, + string $policyId, + array $options, + array $context, + bool $throwOnFailure + ): array { if (! is_string($listPathTemplate) || $listPathTemplate === '') { return []; } @@ -109,14 +163,33 @@ private function fetchPrimary(?string $listPathTemplate, string $policyId, array $response = $this->graphClient->request('GET', $path, $options); + if ($response->failed()) { + $this->logAssignmentFailure('primary', $response, $context + ['path' => $path]); + + if ($throwOnFailure) { + throw new GraphException( + $this->resolveErrorMessage($response), + $response->status, + $context + ['path' => $path] + ); + } + + return []; + } + return $response->data['value'] ?? []; } /** * Fetch assignments using $expand fallback. */ - private function fetchWithExpand(string $resource, string $policyId, array $options): array - { + private function fetchWithExpand( + string $resource, + string $policyId, + array $options, + array $context, + bool $throwOnFailure + ): array { $path = $resource; $params = [ '$expand' => 'assignments', @@ -127,6 +200,20 @@ private function fetchWithExpand(string $resource, string $policyId, array $opti 'query' => $params, ])); + if ($response->failed()) { + $this->logAssignmentFailure('fallback', $response, $context + ['path' => $path]); + + if ($throwOnFailure) { + throw new GraphException( + $this->resolveErrorMessage($response), + $response->status, + $context + ['path' => $path] + ); + } + + return []; + } + $policies = $response->data['value'] ?? []; if (empty($policies)) { @@ -144,4 +231,32 @@ private function resolvePath(string $template, string $policyId): ?string return str_replace('{id}', urlencode($policyId), $template); } + + private function resolveErrorMessage(GraphResponse $response): string + { + $error = $response->errors[0] ?? null; + + if (is_array($error)) { + if (isset($error['message']) && is_string($error['message'])) { + return $error['message']; + } + + return json_encode($error, JSON_UNESCAPED_SLASHES) ?: 'Graph request failed'; + } + + if (is_string($error) && $error !== '') { + return $error; + } + + return 'Graph request failed'; + } + + private function logAssignmentFailure(string $stage, GraphResponse $response, array $context): void + { + Log::warning('Assignment fetch failed', $context + [ + 'stage' => $stage, + 'status' => $response->status, + 'errors' => $response->errors, + ]); + } } diff --git a/app/Services/Intune/BackupService.php b/app/Services/Intune/BackupService.php index b41dd01..22abe4c 100644 --- a/app/Services/Intune/BackupService.php +++ b/app/Services/Intune/BackupService.php @@ -42,6 +42,7 @@ public function createBackupSet( $policies = Policy::query() ->where('tenant_id', $tenant->id) ->whereIn('id', $policyIds) + ->whereNull('ignored_at') ->get(); $backupSet = DB::transaction(function () use ($tenant, $policies, $actorEmail, $name, $includeAssignments, $includeScopeTags, $includeFoundations) { @@ -182,6 +183,7 @@ public function addPoliciesToSet( $policies = Policy::query() ->where('tenant_id', $tenant->id) ->whereIn('id', $policyIds) + ->whereNull('ignored_at') ->get(); $metadata = $backupSet->metadata ?? []; @@ -303,6 +305,12 @@ private function snapshotPolicy( $metadata['warnings'] = array_values(array_unique($metadataWarnings)); } + $capturedScopeTags = $captured['scope_tags'] ?? null; + if (is_array($capturedScopeTags)) { + $metadata['scope_tag_ids'] = $capturedScopeTags['ids'] ?? null; + $metadata['scope_tag_names'] = $capturedScopeTags['names'] ?? null; + } + // Create BackupItem as a copy/reference of the PolicyVersion $backupItem = BackupItem::create([ 'tenant_id' => $tenant->id, diff --git a/app/Services/Intune/PolicyCaptureOrchestrator.php b/app/Services/Intune/PolicyCaptureOrchestrator.php index b2f0971..63e6f27 100644 --- a/app/Services/Intune/PolicyCaptureOrchestrator.php +++ b/app/Services/Intune/PolicyCaptureOrchestrator.php @@ -58,7 +58,15 @@ public function capture( // 2. Fetch assignments if requested if ($includeAssignments) { try { - $rawAssignments = $this->assignmentFetcher->fetch($policy->policy_type, $tenantIdentifier, $policy->external_id, $graphOptions); + $rawAssignments = $this->assignmentFetcher->fetch( + $policy->policy_type, + $tenantIdentifier, + $policy->external_id, + $graphOptions, + true + ); + $captureMetadata['assignments_fetched'] = true; + $captureMetadata['assignments_count'] = count($rawAssignments); if (! empty($rawAssignments)) { $resolvedGroups = []; @@ -77,12 +85,7 @@ public function capture( ->contains(fn (array $group) => $group['orphaned'] ?? false); } - $filterIds = collect($rawAssignments) - ->pluck('target.deviceAndAppManagementAssignmentFilterId') - ->filter() - ->unique() - ->values() - ->all(); + $filterIds = $this->extractAssignmentFilterIds($rawAssignments); $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); $filterNames = collect($filters) @@ -90,7 +93,6 @@ public function capture( ->all(); $assignments = $this->enrichAssignments($rawAssignments, $resolvedGroups, $filterNames); - $captureMetadata['assignments_count'] = count($rawAssignments); } } catch (\Throwable $e) { $captureMetadata['assignments_fetch_failed'] = true; @@ -242,7 +244,15 @@ public function ensureVersionHasAssignments( if ($includeAssignments && $version->assignments === null) { try { - $rawAssignments = $this->assignmentFetcher->fetch($policy->policy_type, $tenantIdentifier, $policy->external_id, $graphOptions); + $rawAssignments = $this->assignmentFetcher->fetch( + $policy->policy_type, + $tenantIdentifier, + $policy->external_id, + $graphOptions, + true + ); + $metadata['assignments_fetched'] = true; + $metadata['assignments_count'] = count($rawAssignments); if (! empty($rawAssignments)) { $resolvedGroups = []; @@ -261,12 +271,7 @@ public function ensureVersionHasAssignments( ->contains(fn (array $group) => $group['orphaned'] ?? false); } - $filterIds = collect($rawAssignments) - ->pluck('target.deviceAndAppManagementAssignmentFilterId') - ->filter() - ->unique() - ->values() - ->all(); + $filterIds = $this->extractAssignmentFilterIds($rawAssignments); $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); $filterNames = collect($filters) @@ -274,7 +279,6 @@ public function ensureVersionHasAssignments( ->all(); $assignments = $this->enrichAssignments($rawAssignments, $resolvedGroups, $filterNames); - $metadata['assignments_count'] = count($rawAssignments); } } catch (\Throwable $e) { $metadata['assignments_fetch_failed'] = true; @@ -336,9 +340,21 @@ private function enrichAssignments(array $assignments, array $groups, array $fil $target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false; } - $filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; - if ($filterId && isset($filterNames[$filterId])) { - $target['assignment_filter_name'] = $filterNames[$filterId]; + $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] + ?? ($target['deviceAndAppManagementAssignmentFilterId'] ?? null); + $filterType = $assignment['deviceAndAppManagementAssignmentFilterType'] + ?? ($target['deviceAndAppManagementAssignmentFilterType'] ?? null); + + if ($filterId) { + $target['deviceAndAppManagementAssignmentFilterId'] = $target['deviceAndAppManagementAssignmentFilterId'] ?? $filterId; + + if ($filterType) { + $target['deviceAndAppManagementAssignmentFilterType'] = $target['deviceAndAppManagementAssignmentFilterType'] ?? $filterType; + } + + if (isset($filterNames[$filterId])) { + $target['assignment_filter_name'] = $filterNames[$filterId]; + } } $assignment['target'] = $target; @@ -347,6 +363,30 @@ private function enrichAssignments(array $assignments, array $groups, array $fil }, $assignments); } + /** + * @param array> $assignments + * @return array + */ + private function extractAssignmentFilterIds(array $assignments): array + { + $filterIds = []; + + foreach ($assignments as $assignment) { + if (! is_array($assignment)) { + continue; + } + + $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] + ?? ($assignment['target']['deviceAndAppManagementAssignmentFilterId'] ?? null); + + if (is_string($filterId) && $filterId !== '') { + $filterIds[] = $filterId; + } + } + + return array_values(array_unique($filterIds)); + } + /** * @param array $scopeTagIds * @return array{ids:array,names:array} diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index 0cbb466..c5a36a1 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -276,7 +276,7 @@ public function execute( settings: $settings, graphOptions: $graphOptions, context: $context, - fallbackName: $item->policy_identifier, + fallbackName: $item->resolvedDisplayName(), ); if ($createOutcome['success']) { @@ -385,6 +385,7 @@ public function execute( $assignmentOutcomes = null; $assignmentSummary = null; + $restoredAssignments = null; if (! $dryRun && is_array($item->assignments) && $item->assignments !== []) { $assignmentPolicyId = $createdPolicyId ?? $item->policy_identifier; @@ -410,6 +411,19 @@ public function execute( } + if (is_array($assignmentOutcomes)) { + $restoredAssignments = collect($assignmentOutcomes['outcomes'] ?? []) + ->filter(fn (array $outcome) => ($outcome['status'] ?? null) === 'success') + ->pluck('assignment') + ->filter() + ->values() + ->all(); + + if ($restoredAssignments === []) { + $restoredAssignments = null; + } + } + if (is_array($complianceActionSummary) && ($complianceActionSummary['skipped'] ?? 0) > 0 && $itemStatus === 'applied') { $itemStatus = 'partial'; $resultReason = 'Compliance notification actions skipped'; @@ -486,7 +500,8 @@ public function execute( 'source' => 'restore', 'restore_run_id' => $restoreRun->id, 'backup_item_id' => $item->id, - ] + ], + assignments: $restoredAssignments, ); } } @@ -1365,6 +1380,11 @@ private function createAutopilotDeploymentProfileIfMissing( $resource = $this->contracts->resourcePath('windowsAutopilotDeploymentProfile') ?? 'deviceManagement/windowsAutopilotDeploymentProfiles'; $payload = $this->contracts->sanitizeUpdatePayload('windowsAutopilotDeploymentProfile', $originalPayload); + $payload['displayName'] = $this->prefixRestoredName( + $this->resolvePayloadString($payload, ['displayName', 'name']), + $policyId + ); + unset($payload['name']); if ($payload === []) { return [ @@ -1463,7 +1483,7 @@ private function buildSettingsCatalogCreatePayload( $payload = []; $name = $this->resolvePayloadString($originalPayload, ['name', 'displayName']); - $payload['name'] = $name ?? sprintf('Restored %s', $fallbackName); + $payload['name'] = $this->prefixRestoredName($name, $fallbackName); $description = $this->resolvePayloadString($originalPayload, ['description', 'Description']); if ($description !== null) { @@ -1505,6 +1525,24 @@ private function buildSettingsCatalogCreatePayload( return $payload; } + private function prefixRestoredName(?string $name, string $fallback): string + { + $prefix = 'Restored_'; + $base = trim((string) ($name ?? $fallback)); + + if ($base === '') { + $base = $fallback; + } + + $normalized = strtolower($base); + + if (str_starts_with($normalized, 'restored_') || str_starts_with($normalized, 'restored ')) { + return $base; + } + + return $prefix.$base; + } + /** * @param array $payload * @param array $keys diff --git a/app/Services/Intune/VersionService.php b/app/Services/Intune/VersionService.php index 214fcb6..8186f53 100644 --- a/app/Services/Intune/VersionService.php +++ b/app/Services/Intune/VersionService.php @@ -91,7 +91,15 @@ public function captureFromGraph( if ($includeAssignments) { try { - $rawAssignments = $this->assignmentFetcher->fetch($policy->policy_type, $tenantIdentifier, $policy->external_id, $graphOptions); + $rawAssignments = $this->assignmentFetcher->fetch( + $policy->policy_type, + $tenantIdentifier, + $policy->external_id, + $graphOptions, + true + ); + $assignmentMetadata['assignments_fetched'] = true; + $assignmentMetadata['assignments_count'] = count($rawAssignments); if (! empty($rawAssignments)) { $resolvedGroups = []; @@ -110,14 +118,8 @@ public function captureFromGraph( $assignmentMetadata['has_orphaned_assignments'] = collect($resolvedGroups) ->contains(fn (array $group) => $group['orphaned'] ?? false); - $assignmentMetadata['assignments_count'] = count($rawAssignments); - $filterIds = collect($rawAssignments) - ->pluck('target.deviceAndAppManagementAssignmentFilterId') - ->filter() - ->unique() - ->values() - ->all(); + $filterIds = $this->extractAssignmentFilterIds($rawAssignments); $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); $filterNames = collect($filters) @@ -170,9 +172,21 @@ private function enrichAssignments(array $assignments, array $groups, array $fil $target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false; } - $filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; - if ($filterId && isset($filterNames[$filterId])) { - $target['assignment_filter_name'] = $filterNames[$filterId]; + $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] + ?? ($target['deviceAndAppManagementAssignmentFilterId'] ?? null); + $filterType = $assignment['deviceAndAppManagementAssignmentFilterType'] + ?? ($target['deviceAndAppManagementAssignmentFilterType'] ?? null); + + if ($filterId) { + $target['deviceAndAppManagementAssignmentFilterId'] = $target['deviceAndAppManagementAssignmentFilterId'] ?? $filterId; + + if ($filterType) { + $target['deviceAndAppManagementAssignmentFilterType'] = $target['deviceAndAppManagementAssignmentFilterType'] ?? $filterType; + } + + if (isset($filterNames[$filterId])) { + $target['assignment_filter_name'] = $filterNames[$filterId]; + } } $assignment['target'] = $target; @@ -181,6 +195,30 @@ private function enrichAssignments(array $assignments, array $groups, array $fil }, $assignments); } + /** + * @param array> $assignments + * @return array + */ + private function extractAssignmentFilterIds(array $assignments): array + { + $filterIds = []; + + foreach ($assignments as $assignment) { + if (! is_array($assignment)) { + continue; + } + + $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] + ?? ($assignment['target']['deviceAndAppManagementAssignmentFilterId'] ?? null); + + if (is_string($filterId) && $filterId !== '') { + $filterIds[] = $filterId; + } + } + + return array_values(array_unique($filterIds)); + } + /** * @param array $scopeTagIds * @return array{ids:array,names:array} diff --git a/config/graph_contracts.php b/config/graph_contracts.php index d637985..b181301 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -196,6 +196,7 @@ 'assignments_list_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments', 'assignments_create_path' => '/deviceManagement/deviceManagementScripts/{id}/assign', 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'deviceManagementScriptAssignments', 'assignments_update_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments/{assignmentId}', 'assignments_update_method' => 'PATCH', 'assignments_delete_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments/{assignmentId}', @@ -215,6 +216,7 @@ 'assignments_list_path' => '/deviceManagement/deviceShellScripts/{id}/assignments', 'assignments_create_path' => '/deviceManagement/deviceShellScripts/{id}/assign', 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'deviceManagementScriptAssignments', 'assignments_update_path' => '/deviceManagement/deviceShellScripts/{id}/assignments/{assignmentId}', 'assignments_update_method' => 'PATCH', 'assignments_delete_path' => '/deviceManagement/deviceShellScripts/{id}/assignments/{assignmentId}', @@ -234,6 +236,7 @@ 'assignments_list_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments', 'assignments_create_path' => '/deviceManagement/deviceHealthScripts/{id}/assign', 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'deviceHealthScriptAssignments', 'assignments_update_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}', 'assignments_update_method' => 'PATCH', 'assignments_delete_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}', @@ -251,6 +254,10 @@ 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'enrollmentConfigurationAssignments', ], 'windowsAutopilotDeploymentProfile' => [ 'resource' => 'deviceManagement/windowsAutopilotDeploymentProfiles', @@ -290,6 +297,10 @@ 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'enrollmentConfigurationAssignments', ], 'endpointSecurityIntent' => [ 'resource' => 'deviceManagement/intents', diff --git a/resources/views/livewire/policy-version-assignments-widget.blade.php b/resources/views/livewire/policy-version-assignments-widget.blade.php index fb6238b..f466235 100644 --- a/resources/views/livewire/policy-version-assignments-widget.blade.php +++ b/resources/views/livewire/policy-version-assignments-widget.blade.php @@ -118,9 +118,29 @@

Assignments

-

- Assignments were not captured for this version. -

+ @php + $assignmentsFetched = $version->metadata['assignments_fetched'] ?? false; + $assignmentsFetchFailed = $version->metadata['assignments_fetch_failed'] ?? false; + $assignmentsFetchError = $version->metadata['assignments_fetch_error'] ?? null; + @endphp + @if($assignmentsFetchFailed) +

+ Assignments could not be fetched from Microsoft Graph. +

+ @if($assignmentsFetchError) +

+ {{ $assignmentsFetchError }} +

+ @endif + @elseif($assignmentsFetched) +

+ No assignments found for this version. +

+ @else +

+ Assignments were not captured for this version. +

+ @endif @php $hasBackupItem = $version->policy->backupItems() ->whereNotNull('assignments') diff --git a/specs/007-device-config-compliance/tasks.md b/specs/007-device-config-compliance/tasks.md index ab6fa32..e5a8981 100644 --- a/specs/007-device-config-compliance/tasks.md +++ b/specs/007-device-config-compliance/tasks.md @@ -42,9 +42,9 @@ ## Phase 3: Restore Logic and Mapping **Purpose**: Restore new policy types safely using assignment and foundation mappings. - [ ] T009 Update `app/Services/Intune/RestoreService.php` to restore the new policy types using Graph contracts. -- [ ] T010 Extend `app/Services/AssignmentRestoreService.php` for assignment endpoints of the new types. -- [ ] T011 Ensure compliance notification templates are restored and referenced via mapping in `app/Services/Intune/RestoreService.php`. -- [ ] T012 Add audit coverage for compliance action mapping outcomes in `app/Services/Intune/AuditLogger.php`. +- [x] T010 Extend `app/Services/AssignmentRestoreService.php` for assignment endpoints of the new types. +- [x] T011 Ensure compliance notification templates are restored and referenced via mapping in `app/Services/Intune/RestoreService.php`. +- [x] T012 Add audit coverage for compliance action mapping outcomes in `app/Services/Intune/AuditLogger.php`. **Checkpoint**: Restore applies policies and assignments or skips with clear reasons. diff --git a/tests/Feature/Filament/BackupCreationTest.php b/tests/Feature/Filament/BackupCreationTest.php index 896b6f3..e619c15 100644 --- a/tests/Feature/Filament/BackupCreationTest.php +++ b/tests/Feature/Filament/BackupCreationTest.php @@ -19,7 +19,7 @@ // Mock PolicySnapshotService $this->mock(PolicySnapshotService::class, function (MockInterface $mock) { $mock->shouldReceive('fetch') - ->twice() // Called once for each policy + ->once() // Called once for the active policy ->andReturnUsing(function ($tenant, $policy) { return [ 'payload' => [ @@ -96,6 +96,7 @@ public function request(string $method, string $path, array $options = []): Grap 'display_name' => 'Policy B', 'platform' => 'windows', 'last_synced_at' => now(), + 'ignored_at' => now(), ]); $user = User::factory()->create(); @@ -109,15 +110,15 @@ public function request(string $method, string $path, array $options = []): Grap 'ownerRecord' => $backupSet, 'pageClass' => \App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class, ])->callTableAction('addPolicies', data: [ - 'policy_ids' => [$policyA->id, $policyB->id], + 'policy_ids' => [$policyA->id], 'include_assignments' => false, 'include_scope_tags' => true, ]); $backupSet->refresh(); - expect($backupSet->item_count)->toBe(2); - expect($backupSet->items)->toHaveCount(2); + expect($backupSet->item_count)->toBe(1); + expect($backupSet->items)->toHaveCount(1); expect($backupSet->items->first()->payload['id'])->toBe('policy-1'); $firstVersion = PolicyVersion::find($backupSet->items->first()->policy_version_id); @@ -140,3 +141,61 @@ public function request(string $method, string $path, array $options = []): Grap 'resource_id' => (string) $backupSet->id, ]); }); + +test('backup service skips ignored policies', function () { + $this->mock(PolicySnapshotService::class, function (MockInterface $mock) { + $mock->shouldReceive('fetch') + ->once() + ->andReturnUsing(function ($tenant, $policy) { + return [ + 'payload' => [ + 'id' => $policy->external_id, + 'name' => $policy->display_name, + 'roleScopeTagIds' => ['0'], + ], + 'metadata' => [], + 'warnings' => [], + ]; + }); + }); + + $tenant = Tenant::create([ + 'name' => 'Test tenant', + 'external_id' => 'tenant-1', + 'tenant_id' => 'tenant-1', + 'status' => 'active', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $policyA = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Policy A', + 'platform' => 'windows', + 'last_synced_at' => now(), + ]); + + $policyB = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-2', + 'policy_type' => 'deviceCompliancePolicy', + 'display_name' => 'Policy B', + 'platform' => 'windows', + 'last_synced_at' => now(), + 'ignored_at' => now(), + ]); + + $service = app(\App\Services\Intune\BackupService::class); + $backupSet = $service->createBackupSet( + tenant: $tenant, + policyIds: [$policyA->id, $policyB->id], + actorEmail: 'tester@example.com', + actorName: 'Tester', + ); + + expect($backupSet->item_count)->toBe(1); + expect($backupSet->items->pluck('policy_id')->all())->toBe([$policyA->id]); +}); diff --git a/tests/Feature/Filament/RestoreExecutionTest.php b/tests/Feature/Filament/RestoreExecutionTest.php index b9bf0d0..706e2a0 100644 --- a/tests/Feature/Filament/RestoreExecutionTest.php +++ b/tests/Feature/Filament/RestoreExecutionTest.php @@ -292,6 +292,8 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon public int $createCalls = 0; + public array $createPayloads = []; + public function listPolicies(string $policyType, array $options = []): GraphResponse { return new GraphResponse(true, []); @@ -326,6 +328,7 @@ public function request(string $method, string $path, array $options = []): Grap { if ($method === 'POST' && str_contains($path, 'windowsAutopilotDeploymentProfiles')) { $this->createCalls++; + $this->createPayloads[] = $options['json'] ?? []; return new GraphResponse(true, ['id' => 'autopilot-created']); } @@ -384,6 +387,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon expect($graphClient->applyCalls)->toBe(1); expect($graphClient->getCalls)->toBe(1); expect($graphClient->createCalls)->toBe(1); + expect($graphClient->createPayloads[0]['displayName'] ?? null)->toBe('Restored_Autopilot Profile'); expect($run->status)->toBe('completed'); expect($run->results[0]['status'])->toBe('applied'); expect($run->results[0]['created_policy_id'])->toBe('autopilot-created'); diff --git a/tests/Feature/Filament/RestoreItemSelectionTest.php b/tests/Feature/Filament/RestoreItemSelectionTest.php index 0699485..a3ce170 100644 --- a/tests/Feature/Filament/RestoreItemSelectionTest.php +++ b/tests/Feature/Filament/RestoreItemSelectionTest.php @@ -22,6 +22,14 @@ 'display_name' => 'Policy Display', 'platform' => 'windows', ]); + $ignoredPolicy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-ignored', + 'policy_type' => 'deviceCompliancePolicy', + 'display_name' => 'Ignored Policy', + 'platform' => 'windows', + 'ignored_at' => now(), + ]); $backupSet = BackupSet::factory()->for($tenant)->create([ 'item_count' => 2, @@ -39,6 +47,18 @@ ]) ->create(); + BackupItem::factory() + ->for($tenant) + ->for($backupSet) + ->state([ + 'policy_id' => $ignoredPolicy->id, + 'policy_identifier' => $ignoredPolicy->external_id, + 'policy_type' => $ignoredPolicy->policy_type, + 'platform' => $ignoredPolicy->platform, + 'payload' => ['id' => $ignoredPolicy->external_id], + ]) + ->create(); + BackupItem::factory() ->for($tenant) ->for($backupSet) @@ -65,6 +85,7 @@ 'backup_set_id' => $backupSet->id, ]) ->assertSee('Policy Display') + ->assertDontSee('Ignored Policy') ->assertSee('Scope Tag Alpha') ->assertSee('Settings Catalog Policy') ->assertSee('Scope Tag') diff --git a/tests/Feature/Filament/SettingsCatalogRestoreTest.php b/tests/Feature/Filament/SettingsCatalogRestoreTest.php index 24a7a79..0570dad 100644 --- a/tests/Feature/Filament/SettingsCatalogRestoreTest.php +++ b/tests/Feature/Filament/SettingsCatalogRestoreTest.php @@ -536,7 +536,9 @@ public function request(string $method, string $path, array $options = []): Grap expect($client->requestCalls[1]['path'])->toBe('deviceManagement/configurationPolicies'); expect($client->requestCalls[1]['payload'])->toHaveKey('settings'); expect($client->requestCalls[1]['payload'])->toHaveKey('name'); + expect($client->requestCalls[1]['payload']['name'])->toBe('Restored_Settings Catalog Epsilon'); expect($client->requestCalls[2]['path'])->toBe('deviceManagement/configurationPolicies'); expect($client->requestCalls[2]['payload'])->not->toHaveKey('settings'); expect($client->requestCalls[2]['payload'])->toHaveKey('name'); + expect($client->requestCalls[2]['payload']['name'])->toBe('Restored_Settings Catalog Epsilon'); }); diff --git a/tests/Feature/PolicyVersionViewAssignmentsTest.php b/tests/Feature/PolicyVersionViewAssignmentsTest.php index 239ff72..af5de47 100644 --- a/tests/Feature/PolicyVersionViewAssignmentsTest.php +++ b/tests/Feature/PolicyVersionViewAssignmentsTest.php @@ -92,3 +92,23 @@ $response->assertOk(); $response->assertSee('Assignments were not captured for this version'); }); + +it('shows empty assignments message when assignments were fetched', function () { + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $this->policy->id, + 'version_number' => 1, + 'assignments' => null, + 'metadata' => [ + 'assignments_fetched' => true, + 'assignments_count' => 0, + ], + ]); + + $this->actingAs($this->user); + + $response = $this->get("/admin/policy-versions/{$version->id}"); + + $response->assertOk(); + $response->assertSee('No assignments found for this version'); +}); diff --git a/tests/Feature/VersionCaptureWithAssignmentsTest.php b/tests/Feature/VersionCaptureWithAssignmentsTest.php index 459ef23..2a1ebfb 100644 --- a/tests/Feature/VersionCaptureWithAssignmentsTest.php +++ b/tests/Feature/VersionCaptureWithAssignmentsTest.php @@ -98,6 +98,70 @@ expect($version->assignments[0]['target']['assignment_filter_name'])->toBe('Targeted Devices'); }); +it('hydrates assignment filter names when filter data is stored at root', function () { + $this->mock(PolicySnapshotService::class, function ($mock) { + $mock->shouldReceive('fetch') + ->once() + ->andReturn([ + 'payload' => [ + 'id' => 'test-policy-id', + 'name' => 'Test Policy', + 'settings' => [], + ], + ]); + }); + + $this->mock(AssignmentFetcher::class, function ($mock) { + $mock->shouldReceive('fetch') + ->once() + ->andReturn([ + [ + 'id' => 'assignment-1', + 'intent' => 'apply', + 'deviceAndAppManagementAssignmentFilterId' => 'filter-123', + 'deviceAndAppManagementAssignmentFilterType' => 'include', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-123', + ], + ], + ]); + }); + + $this->mock(GroupResolver::class, function ($mock) { + $mock->shouldReceive('resolveGroupIds') + ->once() + ->andReturn([ + 'group-123' => [ + 'id' => 'group-123', + 'displayName' => 'Test Group', + 'orphaned' => false, + ], + ]); + }); + + $this->mock(AssignmentFilterResolver::class, function ($mock) { + $mock->shouldReceive('resolve') + ->once() + ->andReturn([ + ['id' => 'filter-123', 'displayName' => 'Targeted Devices'], + ]); + }); + + $versionService = app(VersionService::class); + $version = $versionService->captureFromGraph( + $this->tenant, + $this->policy, + 'test@example.com' + ); + + expect($version->assignments)->not->toBeNull() + ->and($version->assignments)->toHaveCount(1) + ->and($version->assignments[0]['target']['deviceAndAppManagementAssignmentFilterId'])->toBe('filter-123') + ->and($version->assignments[0]['target']['deviceAndAppManagementAssignmentFilterType'])->toBe('include') + ->and($version->assignments[0]['target']['assignment_filter_name'])->toBe('Targeted Devices'); +}); + it('captures policy version without assignments when none exist', function () { // Mock dependencies $this->mock(PolicySnapshotService::class, function ($mock) { @@ -127,7 +191,9 @@ expect($version)->not->toBeNull() ->and($version->assignments)->toBeNull() - ->and($version->assignments_hash)->toBeNull(); + ->and($version->assignments_hash)->toBeNull() + ->and($version->metadata['assignments_fetched'])->toBeTrue() + ->and($version->metadata['assignments_count'])->toBe(0); }); it('handles assignment fetch failure gracefully', function () { diff --git a/tests/Unit/AssignmentBackupServiceTest.php b/tests/Unit/AssignmentBackupServiceTest.php new file mode 100644 index 0000000..d66a058 --- /dev/null +++ b/tests/Unit/AssignmentBackupServiceTest.php @@ -0,0 +1,93 @@ +create([ + 'tenant_id' => 'tenant-123', + 'external_id' => 'tenant-123', + ]); + + $backupItem = BackupItem::factory()->create([ + 'tenant_id' => $tenant->id, + 'metadata' => [], + 'assignments' => null, + ]); + + $policyPayload = [ + 'roleScopeTagIds' => ['0'], + ]; + + $this->mock(AssignmentFetcher::class, function (MockInterface $mock) { + $mock->shouldReceive('fetch') + ->once() + ->andReturn([ + [ + 'id' => 'assignment-1', + 'intent' => 'apply', + 'deviceAndAppManagementAssignmentFilterId' => 'filter-123', + 'deviceAndAppManagementAssignmentFilterType' => 'include', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-123', + ], + ], + ]); + }); + + $this->mock(GroupResolver::class, function (MockInterface $mock) { + $mock->shouldReceive('resolveGroupIds') + ->once() + ->andReturn([ + 'group-123' => [ + 'id' => 'group-123', + 'displayName' => 'Test Group', + 'orphaned' => false, + ], + ]); + }); + + $this->mock(AssignmentFilterResolver::class, function (MockInterface $mock) use ($tenant) { + $mock->shouldReceive('resolve') + ->once() + ->with(['filter-123'], $tenant) + ->andReturn([ + ['id' => 'filter-123', 'displayName' => 'Targeted Devices'], + ]); + }); + + $this->mock(ScopeTagResolver::class, function (MockInterface $mock) use ($tenant) { + $mock->shouldReceive('resolve') + ->once() + ->with(['0'], $tenant) + ->andReturn([ + ['id' => '0', 'displayName' => 'Default'], + ]); + }); + + $service = app(AssignmentBackupService::class); + $updated = $service->enrichWithAssignments( + backupItem: $backupItem, + tenant: $tenant, + policyType: 'settingsCatalogPolicy', + policyId: 'policy-123', + policyPayload: $policyPayload, + includeAssignments: true + ); + + expect($updated->assignments)->toHaveCount(1) + ->and($updated->assignments[0]['target']['deviceAndAppManagementAssignmentFilterId'])->toBe('filter-123') + ->and($updated->assignments[0]['target']['deviceAndAppManagementAssignmentFilterType'])->toBe('include') + ->and($updated->assignments[0]['target']['assignment_filter_name'])->toBe('Targeted Devices'); +}); diff --git a/tests/Unit/AssignmentFetcherTest.php b/tests/Unit/AssignmentFetcherTest.php index bab434a..2626ab1 100644 --- a/tests/Unit/AssignmentFetcherTest.php +++ b/tests/Unit/AssignmentFetcherTest.php @@ -94,7 +94,7 @@ $this->graphClient ->shouldReceive('request') - ->once() + ->twice() ->andThrow(new GraphException('Graph API error', 500, ['request_id' => 'request-id-123'])); $result = $this->fetcher->fetch($policyType, $tenantId, $policyId); @@ -167,3 +167,30 @@ expect($result)->toBe([]); }); + +test('throws when both endpoints fail with throwOnFailure enabled', function () { + $tenantId = 'tenant-123'; + $policyId = 'policy-456'; + $policyType = 'settingsCatalogPolicy'; + + $failureResponse = new GraphResponse( + success: false, + data: [], + status: 403, + errors: [['message' => 'Forbidden']] + ); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", Mockery::any()) + ->andReturn($failureResponse); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('GET', 'deviceManagement/configurationPolicies', Mockery::any()) + ->andReturn($failureResponse); + + $this->fetcher->fetch($policyType, $tenantId, $policyId, [], true); +})->throws(GraphException::class); diff --git a/tests/Unit/AssignmentRestoreServiceTest.php b/tests/Unit/AssignmentRestoreServiceTest.php new file mode 100644 index 0000000..1d0b96b --- /dev/null +++ b/tests/Unit/AssignmentRestoreServiceTest.php @@ -0,0 +1,202 @@ +set('graph_contracts.types.deviceManagementScript', [ + 'assignments_create_path' => '/deviceManagement/deviceManagementScripts/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'deviceManagementScriptAssignments', + ]); + config()->set('graph_contracts.types.settingsCatalogPolicy', [ + 'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign', + 'assignments_create_method' => 'POST', + ]); + + $this->graphClient = Mockery::mock(GraphClientInterface::class); + $this->auditLogger = Mockery::mock(AuditLogger::class); + $this->filterResolver = Mockery::mock(AssignmentFilterResolver::class); + $this->filterResolver->shouldReceive('resolve')->andReturn([])->byDefault(); + + $this->service = new AssignmentRestoreService( + $this->graphClient, + app(GraphContractRegistry::class), + app(GraphLogger::class), + $this->auditLogger, + $this->filterResolver, + ); +}); + +it('uses the contract assignment payload key for assign actions', function () { + $tenant = Tenant::factory()->make([ + 'tenant_id' => 'tenant-123', + 'app_client_id' => null, + 'app_client_secret' => null, + ]); + $policyId = 'policy-123'; + $assignments = [ + [ + 'id' => 'assignment-1', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-1', + ], + ], + ]; + $expectedAssignments = [ + [ + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-1', + ], + ], + ]; + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('POST', "/deviceManagement/deviceManagementScripts/{$policyId}/assign", Mockery::on( + fn (array $options) => ($options['json']['deviceManagementScriptAssignments'] ?? null) === $expectedAssignments + )) + ->andReturn(new GraphResponse(success: true, data: [])); + + $this->auditLogger + ->shouldReceive('log') + ->once() + ->andReturn(new AuditLog); + + $result = $this->service->restore( + $tenant, + 'deviceManagementScript', + $policyId, + $assignments, + [] + ); + + expect($result['summary']['success'])->toBe(1); + expect($result['summary']['failed'])->toBe(0); + expect($result['summary']['skipped'])->toBe(0); +}); + +it('maps assignment filter ids stored at the root of assignments', function () { + $tenant = Tenant::factory()->make([ + 'tenant_id' => 'tenant-123', + 'app_client_id' => null, + 'app_client_secret' => null, + ]); + $policyId = 'policy-789'; + $assignments = [ + [ + 'id' => 'assignment-1', + 'deviceAndAppManagementAssignmentFilterId' => 'filter-source', + 'deviceAndAppManagementAssignmentFilterType' => 'include', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-1', + ], + ], + ]; + $expectedAssignments = [ + [ + 'deviceAndAppManagementAssignmentFilterId' => 'filter-target', + 'deviceAndAppManagementAssignmentFilterType' => 'include', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-1', + ], + ], + ]; + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('POST', "/deviceManagement/configurationPolicies/{$policyId}/assign", Mockery::on( + fn (array $options) => ($options['json']['assignments'] ?? null) === $expectedAssignments + )) + ->andReturn(new GraphResponse(success: true, data: [])); + + $this->auditLogger + ->shouldReceive('log') + ->once() + ->andReturn(new AuditLog); + + $result = $this->service->restore( + $tenant, + 'settingsCatalogPolicy', + $policyId, + $assignments, + [], + [ + 'assignmentFilter' => [ + 'filter-source' => 'filter-target', + ], + ] + ); + + expect($result['summary']['success'])->toBe(1); + expect($result['summary']['failed'])->toBe(0); + expect($result['summary']['skipped'])->toBe(0); +}); + +it('keeps assignment filters when mapping is missing but filter exists in target', function () { + $tenant = Tenant::factory()->make([ + 'tenant_id' => 'tenant-123', + 'app_client_id' => null, + 'app_client_secret' => null, + ]); + $policyId = 'policy-999'; + $assignments = [ + [ + 'id' => 'assignment-1', + 'deviceAndAppManagementAssignmentFilterId' => 'filter-1', + 'deviceAndAppManagementAssignmentFilterType' => 'include', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-1', + ], + ], + ]; + + $this->filterResolver + ->shouldReceive('resolve') + ->once() + ->with(['filter-1'], $tenant) + ->andReturn([['id' => 'filter-1', 'displayName' => 'Test']]); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('POST', "/deviceManagement/configurationPolicies/{$policyId}/assign", Mockery::on( + fn (array $options) => ($options['json']['assignments'][0]['deviceAndAppManagementAssignmentFilterId'] ?? null) === 'filter-1' + )) + ->andReturn(new GraphResponse(success: true, data: [])); + + $this->auditLogger + ->shouldReceive('log') + ->once() + ->andReturn(new AuditLog); + + $result = $this->service->restore( + $tenant, + 'settingsCatalogPolicy', + $policyId, + $assignments, + [], + [] + ); + + expect($result['summary']['success'])->toBe(1); + expect($result['summary']['failed'])->toBe(0); + expect($result['summary']['skipped'])->toBe(0); +});