fix: improve assignment capture and restore flows

This commit is contained in:
Ahmed Darrazi 2025-12-28 14:51:18 +01:00
parent 5db4440c38
commit 8aa9fd4d0f
21 changed files with 987 additions and 111 deletions

View File

@ -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')

View File

@ -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) {

View File

@ -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<int, array<string, mixed>> $assignments
* @return array<int, string>
*/
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));
}
}

View File

@ -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<int, array<string, mixed>> $assignments
* @return array<int, string>
*/
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) {

View File

@ -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,
]);
}
}

View File

@ -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,

View File

@ -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<int, array<string, mixed>> $assignments
* @return array<int, string>
*/
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<int, string> $scopeTagIds
* @return array{ids:array<int, string>,names:array<int, string>}

View File

@ -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<string, mixed> $payload
* @param array<int, string> $keys

View File

@ -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<int, array<string, mixed>> $assignments
* @return array<int, string>
*/
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<int, string> $scopeTagIds
* @return array{ids:array<int, string>,names:array<int, string>}

View File

@ -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',

View File

@ -118,9 +118,29 @@
<h3 class="text-base font-semibold leading-6 text-gray-950 dark:text-white">
Assignments
</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Assignments were not captured for this version.
</p>
@php
$assignmentsFetched = $version->metadata['assignments_fetched'] ?? false;
$assignmentsFetchFailed = $version->metadata['assignments_fetch_failed'] ?? false;
$assignmentsFetchError = $version->metadata['assignments_fetch_error'] ?? null;
@endphp
@if($assignmentsFetchFailed)
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Assignments could not be fetched from Microsoft Graph.
</p>
@if($assignmentsFetchError)
<p class="mt-2 text-xs text-gray-500 dark:text-gray-500">
{{ $assignmentsFetchError }}
</p>
@endif
@elseif($assignmentsFetched)
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
No assignments found for this version.
</p>
@else
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Assignments were not captured for this version.
</p>
@endif
@php
$hasBackupItem = $version->policy->backupItems()
->whereNotNull('assignments')

View File

@ -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.

View File

@ -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]);
});

View File

@ -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');

View File

@ -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')

View File

@ -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');
});

View File

@ -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');
});

View File

@ -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 () {

View File

@ -0,0 +1,93 @@
<?php
use App\Models\BackupItem;
use App\Models\Tenant;
use App\Services\AssignmentBackupService;
use App\Services\Graph\AssignmentFetcher;
use App\Services\Graph\AssignmentFilterResolver;
use App\Services\Graph\GroupResolver;
use App\Services\Graph\ScopeTagResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery\MockInterface;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
it('enriches assignment filter names when filter data is stored at root', function () {
$tenant = Tenant::factory()->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');
});

View File

@ -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);

View File

@ -0,0 +1,202 @@
<?php
use App\Models\AuditLog;
use App\Models\Tenant;
use App\Services\AssignmentRestoreService;
use App\Services\Graph\AssignmentFilterResolver;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphLogger;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\AuditLogger;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
config()->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);
});