feat/004-assignments-scope-tags #4

Merged
ahmido merged 41 commits from feat/004-assignments-scope-tags into dev 2025-12-23 21:49:59 +00:00
7 changed files with 227 additions and 102 deletions
Showing only changes of commit 92bf7af017 - Show all commits

View File

@ -51,12 +51,13 @@ public function restore(
} }
$contract = $this->contracts->get($policyType); $contract = $this->contracts->get($policyType);
$listPath = $this->resolvePath($contract['assignments_list_path'] ?? null, $policyId);
$deletePathTemplate = $contract['assignments_delete_path'] ?? null;
$createPath = $this->resolvePath($contract['assignments_create_path'] ?? null, $policyId); $createPath = $this->resolvePath($contract['assignments_create_path'] ?? null, $policyId);
$createMethod = strtoupper((string) ($contract['assignments_create_method'] ?? 'POST')); $createMethod = strtoupper((string) ($contract['assignments_create_method'] ?? 'POST'));
$usesAssignAction = is_string($createPath) && str_ends_with($createPath, '/assign');
$listPath = $this->resolvePath($contract['assignments_list_path'] ?? null, $policyId);
$deletePathTemplate = $contract['assignments_delete_path'] ?? null;
if (! $listPath || ! $createPath || ! $deletePathTemplate) { if (! $createPath || (! $usesAssignAction && (! $listPath || ! $deletePathTemplate))) {
$outcomes[] = $this->failureOutcome(null, 'Assignments endpoints are not configured for this policy type.'); $outcomes[] = $this->failureOutcome(null, 'Assignments endpoints are not configured for this policy type.');
$summary['failed']++; $summary['failed']++;
@ -76,6 +77,138 @@ public function restore(
'restore_run_id' => $restoreRun?->id, 'restore_run_id' => $restoreRun?->id,
]; ];
$preparedAssignments = [];
$preparedMeta = [];
foreach ($assignments as $assignment) {
if (! is_array($assignment)) {
continue;
}
$groupId = $assignment['target']['groupId'] ?? null;
$mappedGroupId = $groupId && isset($groupMapping[$groupId]) ? $groupMapping[$groupId] : null;
if ($mappedGroupId === 'SKIP') {
$outcomes[] = $this->skipOutcome($assignment, $groupId, $mappedGroupId);
$summary['skipped']++;
$this->logAssignmentOutcome(
status: 'skipped',
tenant: $tenant,
assignment: $assignment,
restoreRun: $restoreRun,
actorEmail: $actorEmail,
actorName: $actorName,
metadata: [
'policy_id' => $policyId,
'policy_type' => $policyType,
'group_id' => $groupId,
'mapped_group_id' => $mappedGroupId,
]
);
continue;
}
$assignmentToRestore = $this->applyGroupMapping($assignment, $mappedGroupId);
$assignmentToRestore = $this->sanitizeAssignment($assignmentToRestore);
$preparedAssignments[] = $assignmentToRestore;
$preparedMeta[] = [
'assignment' => $assignment,
'group_id' => $groupId,
'mapped_group_id' => $mappedGroupId,
];
}
if ($preparedAssignments === []) {
return [
'outcomes' => $outcomes,
'summary' => $summary,
];
}
if ($usesAssignAction) {
$this->graphLogger->logRequest('restore_assignments_assign', $context + [
'method' => $createMethod,
'endpoint' => $createPath,
'assignments' => count($preparedAssignments),
]);
$assignResponse = $this->graphClient->request($createMethod, $createPath, [
'json' => ['assignments' => $preparedAssignments],
] + $graphOptions);
$this->graphLogger->logResponse('restore_assignments_assign', $assignResponse, $context + [
'method' => $createMethod,
'endpoint' => $createPath,
'assignments' => count($preparedAssignments),
]);
if ($assignResponse->successful()) {
foreach ($preparedMeta as $meta) {
$outcomes[] = $this->successOutcome(
$meta['assignment'],
$meta['group_id'],
$meta['mapped_group_id']
);
$summary['success']++;
$this->logAssignmentOutcome(
status: 'created',
tenant: $tenant,
assignment: $meta['assignment'],
restoreRun: $restoreRun,
actorEmail: $actorEmail,
actorName: $actorName,
metadata: [
'policy_id' => $policyId,
'policy_type' => $policyType,
'group_id' => $meta['group_id'],
'mapped_group_id' => $meta['mapped_group_id'],
]
);
}
} else {
$reason = $assignResponse->meta['error_message'] ?? 'Graph assign failed';
if ($preparedMeta === []) {
$outcomes[] = $this->failureOutcome(null, $reason, null, null, $assignResponse);
$summary['failed']++;
}
foreach ($preparedMeta as $meta) {
$outcomes[] = $this->failureOutcome(
$meta['assignment'],
$reason,
$meta['group_id'],
$meta['mapped_group_id'],
$assignResponse
);
$summary['failed']++;
$this->logAssignmentOutcome(
status: 'failed',
tenant: $tenant,
assignment: $meta['assignment'],
restoreRun: $restoreRun,
actorEmail: $actorEmail,
actorName: $actorName,
metadata: [
'policy_id' => $policyId,
'policy_type' => $policyType,
'group_id' => $meta['group_id'],
'mapped_group_id' => $meta['mapped_group_id'],
'graph_error_message' => $assignResponse->meta['error_message'] ?? null,
'graph_error_code' => $assignResponse->meta['error_code'] ?? null,
],
);
}
}
return [
'outcomes' => $outcomes,
'summary' => $summary,
];
}
$this->graphLogger->logRequest('restore_assignments_list', $context + [ $this->graphLogger->logRequest('restore_assignments_list', $context + [
'method' => 'GET', 'method' => 'GET',
'endpoint' => $listPath, 'endpoint' => $listPath,
@ -126,43 +259,18 @@ public function restore(
} }
} }
foreach ($assignments as $assignment) { foreach ($preparedMeta as $index => $meta) {
if (! is_array($assignment)) { $assignmentToRestore = $preparedAssignments[$index] ?? null;
if (! is_array($assignmentToRestore)) {
continue; continue;
} }
$groupId = $assignment['target']['groupId'] ?? null;
$mappedGroupId = $groupId && isset($groupMapping[$groupId]) ? $groupMapping[$groupId] : null;
if ($mappedGroupId === 'SKIP') {
$outcomes[] = $this->skipOutcome($assignment, $groupId, $mappedGroupId);
$summary['skipped']++;
$this->logAssignmentOutcome(
status: 'skipped',
tenant: $tenant,
assignment: $assignment,
restoreRun: $restoreRun,
actorEmail: $actorEmail,
actorName: $actorName,
metadata: [
'policy_id' => $policyId,
'policy_type' => $policyType,
'group_id' => $groupId,
'mapped_group_id' => $mappedGroupId,
]
);
continue;
}
$assignmentToRestore = $this->applyGroupMapping($assignment, $mappedGroupId);
$assignmentToRestore = $this->sanitizeAssignment($assignmentToRestore);
$this->graphLogger->logRequest('restore_assignments_create', $context + [ $this->graphLogger->logRequest('restore_assignments_create', $context + [
'method' => $createMethod, 'method' => $createMethod,
'endpoint' => $createPath, 'endpoint' => $createPath,
'group_id' => $groupId, 'group_id' => $meta['group_id'],
'mapped_group_id' => $mappedGroupId, 'mapped_group_id' => $meta['mapped_group_id'],
]); ]);
$createResponse = $this->graphClient->request($createMethod, $createPath, [ $createResponse = $this->graphClient->request($createMethod, $createPath, [
@ -172,48 +280,48 @@ public function restore(
$this->graphLogger->logResponse('restore_assignments_create', $createResponse, $context + [ $this->graphLogger->logResponse('restore_assignments_create', $createResponse, $context + [
'method' => $createMethod, 'method' => $createMethod,
'endpoint' => $createPath, 'endpoint' => $createPath,
'group_id' => $groupId, 'group_id' => $meta['group_id'],
'mapped_group_id' => $mappedGroupId, 'mapped_group_id' => $meta['mapped_group_id'],
]); ]);
if ($createResponse->successful()) { if ($createResponse->successful()) {
$outcomes[] = $this->successOutcome($assignment, $groupId, $mappedGroupId); $outcomes[] = $this->successOutcome($meta['assignment'], $meta['group_id'], $meta['mapped_group_id']);
$summary['success']++; $summary['success']++;
$this->logAssignmentOutcome( $this->logAssignmentOutcome(
status: 'created', status: 'created',
tenant: $tenant, tenant: $tenant,
assignment: $assignment, assignment: $meta['assignment'],
restoreRun: $restoreRun, restoreRun: $restoreRun,
actorEmail: $actorEmail, actorEmail: $actorEmail,
actorName: $actorName, actorName: $actorName,
metadata: [ metadata: [
'policy_id' => $policyId, 'policy_id' => $policyId,
'policy_type' => $policyType, 'policy_type' => $policyType,
'group_id' => $groupId, 'group_id' => $meta['group_id'],
'mapped_group_id' => $mappedGroupId, 'mapped_group_id' => $meta['mapped_group_id'],
] ]
); );
} else { } else {
$outcomes[] = $this->failureOutcome( $outcomes[] = $this->failureOutcome(
$assignment, $meta['assignment'],
$createResponse->meta['error_message'] ?? 'Graph create failed', $createResponse->meta['error_message'] ?? 'Graph create failed',
$groupId, $meta['group_id'],
$mappedGroupId, $meta['mapped_group_id'],
$createResponse $createResponse
); );
$summary['failed']++; $summary['failed']++;
$this->logAssignmentOutcome( $this->logAssignmentOutcome(
status: 'failed', status: 'failed',
tenant: $tenant, tenant: $tenant,
assignment: $assignment, assignment: $meta['assignment'],
restoreRun: $restoreRun, restoreRun: $restoreRun,
actorEmail: $actorEmail, actorEmail: $actorEmail,
actorName: $actorName, actorName: $actorName,
metadata: [ metadata: [
'policy_id' => $policyId, 'policy_id' => $policyId,
'policy_type' => $policyType, 'policy_type' => $policyType,
'group_id' => $groupId, 'group_id' => $meta['group_id'],
'mapped_group_id' => $mappedGroupId, 'mapped_group_id' => $meta['mapped_group_id'],
'graph_error_message' => $createResponse->meta['error_message'] ?? null, 'graph_error_message' => $createResponse->meta['error_message'] ?? null,
'graph_error_code' => $createResponse->meta['error_code'] ?? null, 'graph_error_code' => $createResponse->meta['error_code'] ?? null,
], ],

View File

@ -73,7 +73,7 @@
// Assignments CRUD (standard Graph pattern) // Assignments CRUD (standard Graph pattern)
'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments', 'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments',
'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assignments', 'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign',
'assignments_create_method' => 'POST', 'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}', 'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH', 'assignments_update_method' => 'PATCH',

View File

@ -42,9 +42,14 @@
</span> </span>
</div> </div>
@if (! empty($item['reason'])) @php
$itemReason = $item['reason'] ?? null;
$itemGraphMessage = $item['graph_error_message'] ?? null;
@endphp
@if (! empty($itemReason) && ($itemGraphMessage === null || $itemGraphMessage !== $itemReason))
<div class="mt-2 text-sm text-gray-800"> <div class="mt-2 text-sm text-gray-800">
{{ $item['reason'] }} {{ $itemReason }}
</div> </div>
@endif @endif
@ -95,9 +100,14 @@
</div> </div>
@endif @endif
@if (! empty($outcome['reason'])) @php
$outcomeReason = $outcome['reason'] ?? null;
$outcomeGraphMessage = $outcome['graph_error_message'] ?? null;
@endphp
@if (! empty($outcomeReason) && ($outcomeGraphMessage === null || $outcomeGraphMessage !== $outcomeReason))
<div class="mt-1 text-[11px] text-gray-800"> <div class="mt-1 text-[11px] text-gray-800">
{{ $outcome['reason'] }} {{ $outcomeReason }}
</div> </div>
@endif @endif

View File

@ -53,23 +53,26 @@
@foreach($version->assignments as $assignment) @foreach($version->assignments as $assignment)
@php @php
$target = $assignment['target'] ?? []; $target = $assignment['target'] ?? [];
$type = $target['@odata.type'] ?? 'unknown'; $type = $target['@odata.type'] ?? '';
$typeKey = strtolower((string) $type);
$intent = $assignment['intent'] ?? 'apply'; $intent = $assignment['intent'] ?? 'apply';
$typeName = match($type) { $typeName = match (true) {
'#microsoft.graph.groupAssignmentTarget' => 'Include group', str_contains($typeKey, 'exclusiongroupassignmenttarget') => 'Exclude group',
'#microsoft.graph.exclusionGroupAssignmentTarget' => 'Exclude group', str_contains($typeKey, 'groupassignmenttarget') => 'Include group',
'#microsoft.graph.allLicensedUsersAssignmentTarget' => 'All Users', str_contains($typeKey, 'alllicensedusersassignmenttarget') => 'All Users',
'#microsoft.graph.allDevicesAssignmentTarget' => 'All Devices', str_contains($typeKey, 'alldevicesassignmenttarget') => 'All Devices',
default => 'Unknown' default => 'Unknown',
}; };
$groupId = $target['groupId'] ?? null; $groupId = $target['groupId'] ?? null;
$groupName = $target['group_display_name'] ?? null; $groupName = $target['group_display_name'] ?? null;
$groupOrphaned = $target['group_orphaned'] ?? ($version->metadata['has_orphaned_assignments'] ?? false); $groupOrphaned = $target['group_orphaned'] ?? ($version->metadata['has_orphaned_assignments'] ?? false);
$filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; $filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null;
$filterType = $target['deviceAndAppManagementAssignmentFilterType'] ?? 'none'; $filterTypeRaw = strtolower((string) ($target['deviceAndAppManagementAssignmentFilterType'] ?? 'none'));
$filterType = $filterTypeRaw !== '' ? $filterTypeRaw : 'none';
$filterName = $target['assignment_filter_name'] ?? null; $filterName = $target['assignment_filter_name'] ?? null;
$filterLabel = $filterName ?? $filterId;
@endphp @endphp
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
@ -96,9 +99,9 @@
@endif @endif
@endif @endif
@if($filterId && $filterType !== 'none') @if($filterLabel)
<span class="text-xs text-gray-500 dark:text-gray-500"> <span class="text-xs text-gray-500 dark:text-gray-500">
Filter ({{ $filterType }}): {{ $filterName ?? $filterId }} Filter{{ $filterType !== 'none' ? " ({$filterType})" : '' }}: {{ $filterLabel }}
</span> </span>
@endif @endif

View File

@ -28,7 +28,8 @@ ## Scope
- **Policy Types**: `settingsCatalogPolicy` only (initially) - **Policy Types**: `settingsCatalogPolicy` only (initially)
- **Graph Endpoints**: - **Graph Endpoints**:
- GET `/deviceManagement/configurationPolicies/{id}/assignments` - GET `/deviceManagement/configurationPolicies/{id}/assignments`
- POST/PATCH `/deviceManagement/configurationPolicies/{id}/assignments` - POST `/deviceManagement/configurationPolicies/{id}/assign` (assign action, replaces assignments)
- DELETE `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}` (fallback)
- GET `/deviceManagement/roleScopeTags` (for reference data) - GET `/deviceManagement/roleScopeTags` (for reference data)
- GET `/deviceManagement/assignmentFilters` (for filter names) - GET `/deviceManagement/assignmentFilters` (for filter names)
- **Backup Behavior**: Optional at capture time with separate checkboxes ("Include assignments", "Include scope tags") on Add Policies and Capture Snapshot actions (defaults: true) - **Backup Behavior**: Optional at capture time with separate checkboxes ("Include assignments", "Include scope tags") on Add Policies and Capture Snapshot actions (defaults: true)
@ -190,10 +191,12 @@ ### Restore with Group Mapping
1. Replace source group IDs with mapped target group IDs in assignment objects 1. Replace source group IDs with mapped target group IDs in assignment objects
2. Skip assignments marked "Skip" in group mapping 2. Skip assignments marked "Skip" in group mapping
3. Preserve include/exclude intent and filters 3. Preserve include/exclude intent and filters
4. Execute restore via DELETE-then-CREATE pattern: 4. Execute restore via assign action when supported:
- Step 1: GET existing assignments from target policy - Step 1: POST `/assign` with `{ assignments: [...] }` to replace assignments
- Step 2: DELETE each existing assignment (via DELETE `/assignments/{id}`) - Step 2 (fallback): If `/assign` is unsupported, use DELETE-then-CREATE:
- Step 3: POST each new/mapped assignment (via POST `/assignments`) - GET existing assignments from target policy
- DELETE each existing assignment (via DELETE `/assignments/{id}`)
- POST each new/mapped assignment (via POST `/assignments`)
5. Handle failures gracefully: 5. Handle failures gracefully:
- 204 No Content on DELETE = success - 204 No Content on DELETE = success
- 201 Created on POST = success - 201 Created on POST = success
@ -316,30 +319,32 @@ ### Endpoints to Add (Production-Tested Strategies)
- Client-side filter to extract assignments - Client-side filter to extract assignments
- **Reason**: Known Graph API quirks with assignment expansion on certain template families - **Reason**: Known Graph API quirks with assignment expansion on certain template families
2. **Assignment CRUD Operations** (Standard Graph Pattern) 2. **Assignment Apply** (Assign action + fallback)
- **POST** `/deviceManagement/configurationPolicies/{id}/assignments` - **POST** `/deviceManagement/configurationPolicies/{id}/assign`
- Body: Single assignment object - Body: `{ "assignments": [ ... ] }`
- Returns: 201 Created with assignment object - Returns: 200/204 on success (no per-assignment IDs)
- Example: - Example:
```json ```json
{ {
"target": { "assignments": [
"@odata.type": "#microsoft.graph.groupAssignmentTarget", {
"groupId": "abc-123-def" "target": {
}, "@odata.type": "#microsoft.graph.groupAssignmentTarget",
"intent": "apply" "groupId": "abc-123-def"
},
"intent": "apply"
}
]
} }
``` ```
- **PATCH** `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}` - **Fallback** (when `/assign` is unsupported):
- Body: Assignment object (partial update) - **GET** `/deviceManagement/configurationPolicies/{id}/assignments`
- Returns: 200 OK with updated assignment - **DELETE** `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}`
- **POST** `/deviceManagement/configurationPolicies/{id}/assignments` (single assignment object)
- **DELETE** `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}` - **Restore Strategy**: Prefer `/assign`; if unsupported, delete existing assignments then POST new ones (best-effort; record outcomes, no transactional rollback).
- Returns: 204 No Content
- **Restore Strategy**: DELETE all existing assignments, then POST new ones (best-effort; record per-assignment outcomes, no transactional rollback)
3. **POST** `/directoryObjects/getByIds` (Stable Group Resolution) 3. **POST** `/directoryObjects/getByIds` (Stable Group Resolution)
- Body: `{ "ids": ["id1", "id2"], "types": ["group"] }` - Body: `{ "ids": ["id1", "id2"], "types": ["group"] }`
@ -372,7 +377,7 @@ ### Graph Contract Updates
// Assignments CRUD (standard Graph pattern) // Assignments CRUD (standard Graph pattern)
'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments', 'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments',
'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assignments', 'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign',
'assignments_create_method' => 'POST', 'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}', 'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH', 'assignments_update_method' => 'PATCH',

View File

@ -77,7 +77,7 @@ ### Tasks
**1.9** [X] ⭐ Update `config/graph_contracts.php` with assignments endpoints **1.9** [X] ⭐ Update `config/graph_contracts.php` with assignments endpoints
- Add `assignments_list_path` (GET) - Add `assignments_list_path` (GET)
- Add `assignments_create_path` (POST) - Add `assignments_create_path` (POST `/assign` for settingsCatalogPolicy)
- Add `assignments_delete_path` (DELETE) - Add `assignments_delete_path` (DELETE)
- Add `supports_scope_tags: true` - Add `supports_scope_tags: true`
- Add `scope_tag_field: 'roleScopeTagIds'` - Add `scope_tag_field: 'roleScopeTagIds'`
@ -351,15 +351,15 @@ ### Tasks
**5.8** Create service: `AssignmentRestoreService` **5.8** Create service: `AssignmentRestoreService`
- File: `app/Services/AssignmentRestoreService.php` - File: `app/Services/AssignmentRestoreService.php`
- Method: `restore(string $policyId, array $assignments, array $groupMapping): array` - Method: `restore(string $policyId, array $assignments, array $groupMapping): array`
- Implement DELETE-then-CREATE pattern - Prefer `/assign` action when supported; fallback to DELETE-then-CREATE pattern
**5.9** Implement DELETE existing assignments **5.9** Implement DELETE existing assignments (fallback)
- Step 1: GET `/assignments` for target policy - Step 1: GET `/assignments` for target policy
- Step 2: Loop and DELETE each assignment - Step 2: Loop and DELETE each assignment
- Handle 204 No Content (success) - Handle 204 No Content (success)
- Log warnings on failure, continue - Log warnings on failure, continue
**5.10** Implement CREATE new assignments with mapping **5.10** Implement CREATE new assignments with mapping (fallback)
- Step 3: Loop through source assignments - Step 3: Loop through source assignments
- Apply group mapping: replace source group IDs with target IDs - Apply group mapping: replace source group IDs with target IDs
- Skip assignments marked `"SKIP"` in mapping - Skip assignments marked `"SKIP"` in mapping
@ -367,7 +367,7 @@ ### Tasks
- Handle 201 Created (success) - Handle 201 Created (success)
- Log per-assignment outcome - Log per-assignment outcome
**5.11** Add rate limit protection **5.11** Add rate limit protection (fallback only)
- Add 100ms delay between sequential POST calls: `usleep(100000)` - Add 100ms delay between sequential POST calls: `usleep(100000)`
- Log request IDs for failed calls - Log request IDs for failed calls

View File

@ -67,10 +67,7 @@ public function request(string $method, string $path, array $options = []): Grap
test('restore applies assignments with mapped groups', function () { test('restore applies assignments with mapped groups', function () {
$applyResponse = new GraphResponse(true, []); $applyResponse = new GraphResponse(true, []);
$requestResponses = [ $requestResponses = [
new GraphResponse(true, ['value' => [['id' => 'assign-old-1']]]), // list new GraphResponse(true, []), // assign action
new GraphResponse(true, [], 204), // delete
new GraphResponse(true, ['id' => 'assign-new-1'], 201), // create 1
new GraphResponse(true, ['id' => 'assign-new-2'], 201), // create 2
]; ];
$client = new RestoreAssignmentGraphClient($applyResponse, $requestResponses); $client = new RestoreAssignmentGraphClient($applyResponse, $requestResponses);
@ -153,23 +150,25 @@ public function request(string $method, string $path, array $options = []): Grap
->filter(fn (array $call) => $call['method'] === 'POST') ->filter(fn (array $call) => $call['method'] === 'POST')
->values(); ->values();
expect($postCalls)->toHaveCount(2); expect($postCalls)->toHaveCount(1);
expect($postCalls[0]['payload']['target']['groupId'])->toBe('target-group-1'); expect($postCalls[0]['path'])->toBe('/deviceManagement/configurationPolicies/scp-1/assign');
expect($postCalls[0]['payload'])->not->toHaveKey('id');
$payloadAssignments = $postCalls[0]['payload']['assignments'] ?? [];
$groupIds = collect($payloadAssignments)->pluck('target.groupId')->all();
expect($groupIds)->toBe(['target-group-1', 'target-group-2']);
expect($payloadAssignments[0])->not->toHaveKey('id');
}); });
test('restore handles assignment failures gracefully', function () { test('restore handles assignment failures gracefully', function () {
$applyResponse = new GraphResponse(true, []); $applyResponse = new GraphResponse(true, []);
$requestResponses = [ $requestResponses = [
new GraphResponse(true, ['value' => [['id' => 'assign-old-1']]]), // list
new GraphResponse(true, [], 204), // delete
new GraphResponse(true, ['id' => 'assign-new-1'], 201), // create 1
new GraphResponse(false, ['error' => ['message' => 'Bad request']], 400, [ new GraphResponse(false, ['error' => ['message' => 'Bad request']], 400, [
['code' => 'BadRequest', 'message' => 'Bad request'], ['code' => 'BadRequest', 'message' => 'Bad request'],
], [], [ ], [], [
'error_code' => 'BadRequest', 'error_code' => 'BadRequest',
'error_message' => 'Bad request', 'error_message' => 'Bad request',
]), // create 2 fails ]), // assign action fails
]; ];
$client = new RestoreAssignmentGraphClient($applyResponse, $requestResponses); $client = new RestoreAssignmentGraphClient($applyResponse, $requestResponses);
@ -244,7 +243,7 @@ public function request(string $method, string $path, array $options = []): Grap
$summary = $run->results[0]['assignment_summary'] ?? null; $summary = $run->results[0]['assignment_summary'] ?? null;
expect($summary)->not->toBeNull(); expect($summary)->not->toBeNull();
expect($summary['success'])->toBe(1); expect($summary['success'])->toBe(0);
expect($summary['failed'])->toBe(1); expect($summary['failed'])->toBe(2);
expect($run->results[0]['status'])->toBe('partial'); expect($run->results[0]['status'])->toBe('partial');
}); });