diff --git a/app/Services/Graph/GraphContractRegistry.php b/app/Services/Graph/GraphContractRegistry.php index 7cf6816..4a4f79b 100644 --- a/app/Services/Graph/GraphContractRegistry.php +++ b/app/Services/Graph/GraphContractRegistry.php @@ -135,6 +135,19 @@ public function settingsWriteBodyShape(string $policyType): string return is_string($shape) && $shape !== '' ? $shape : 'collection'; } + public function settingsWriteFallbackBodyShape(string $policyType): ?string + { + $contract = $this->get($policyType); + $write = $contract['settings_write'] ?? null; + $shape = is_array($write) ? ($write['fallback_body_shape'] ?? null) : null; + + if (! is_string($shape) || $shape === '') { + return null; + } + + return $shape; + } + /** * Sanitize a settings_apply payload for settingsCatalogPolicy. * Preserves `@odata.type` inside `settingInstance` and nested children while diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index a86f15d..45d060c 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -408,6 +408,7 @@ private function applySettingsCatalogPolicySettings( $method = $this->contracts->settingsWriteMethod('settingsCatalogPolicy'); $path = $this->contracts->settingsWritePath('settingsCatalogPolicy', $policyId); $bodyShape = strtolower($this->contracts->settingsWriteBodyShape('settingsCatalogPolicy')); + $fallbackShape = $this->contracts->settingsWriteFallbackBodyShape('settingsCatalogPolicy'); $buildIssues = function (string $reason) use ($settings): array { $issues = []; @@ -455,15 +456,20 @@ private function applySettingsCatalogPolicySettings( ]; } - $payload = match ($bodyShape) { - 'wrapped' => ['settings' => $sanitized], - default => $sanitized, + $buildPayload = function (string $shape) use ($sanitized): array { + return match ($shape) { + 'wrapped' => ['settings' => $sanitized], + default => $sanitized, + }; }; + $payload = $buildPayload($bodyShape); + $this->graphLogger->logRequest('apply_settings_bulk', $context + [ 'endpoint' => $path, 'method' => $method, 'settings_count' => count($sanitized), + 'body_shape' => $bodyShape, ]); $response = $this->graphClient->request($method, $path, ['json' => $payload] + Arr::except($graphOptions, ['platform'])); @@ -472,8 +478,33 @@ private function applySettingsCatalogPolicySettings( 'endpoint' => $path, 'method' => $method, 'settings_count' => count($sanitized), + 'body_shape' => $bodyShape, ]); + if ($response->failed() && is_string($fallbackShape) && strtolower($fallbackShape) !== $bodyShape) { + $fallbackShape = strtolower($fallbackShape); + + if ($this->shouldRetrySettingsBulkApply($response->meta['error_message'] ?? null)) { + $fallbackPayload = $buildPayload($fallbackShape); + + $this->graphLogger->logRequest('apply_settings_bulk_retry', $context + [ + 'endpoint' => $path, + 'method' => $method, + 'settings_count' => count($sanitized), + 'body_shape' => $fallbackShape, + ]); + + $response = $this->graphClient->request($method, $path, ['json' => $fallbackPayload] + Arr::except($graphOptions, ['platform'])); + + $this->graphLogger->logResponse('apply_settings_bulk_retry', $response, $context + [ + 'endpoint' => $path, + 'method' => $method, + 'settings_count' => count($sanitized), + 'body_shape' => $fallbackShape, + ]); + } + } + if ($response->successful()) { return [ [ @@ -508,6 +539,19 @@ private function applySettingsCatalogPolicySettings( ]; } + private function shouldRetrySettingsBulkApply(?string $errorMessage): bool + { + if (! is_string($errorMessage) || $errorMessage === '') { + return false; + } + + $message = strtolower($errorMessage); + + return str_contains($message, 'empty payload') + || str_contains($message, 'json content expected') + || str_contains($message, 'request body'); + } + private function assertActiveContext(Tenant $tenant, BackupSet $backupSet): void { if (! $tenant->isActive()) { diff --git a/config/graph_contracts.php b/config/graph_contracts.php index 27fa505..50beb46 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -67,6 +67,7 @@ 'method' => 'POST', 'bulk' => true, 'body_shape' => 'collection', + 'fallback_body_shape' => 'wrapped', ], 'update_strategy' => 'settings_catalog_policy_with_settings', ], diff --git a/tests/Feature/Filament/SettingsCatalogRestoreTest.php b/tests/Feature/Filament/SettingsCatalogRestoreTest.php index 166788e..63fedde 100644 --- a/tests/Feature/Filament/SettingsCatalogRestoreTest.php +++ b/tests/Feature/Filament/SettingsCatalogRestoreTest.php @@ -305,3 +305,107 @@ public function request(string $method, string $path, array $options = []): Grap expect($client->requestCalls[0]['payload'][0]['settingInstance']['@odata.type']) ->toBe('#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance'); }); + +test('restore retries bulk apply with wrapped payload when graph expects json object', function () { + $policyResponse = new GraphResponse( + success: true, + data: [], + status: 200, + errors: [], + warnings: [], + meta: ['request_id' => 'req-policy', 'client_request_id' => 'client-policy'], + ); + + $firstResponse = new GraphResponse( + success: false, + data: ['error' => ['code' => 'BadRequest', 'message' => 'Empty Payload. JSON content expected.']], + status: 400, + errors: [['code' => 'BadRequest', 'message' => 'Empty Payload. JSON content expected.']], + warnings: [], + meta: [ + 'error_code' => 'BadRequest', + 'error_message' => 'Empty Payload. JSON content expected.', + 'request_id' => 'req-1', + 'client_request_id' => 'client-1', + ], + ); + + $secondResponse = new GraphResponse( + success: true, + data: [], + status: 200, + errors: [], + warnings: [], + meta: ['request_id' => 'req-2', 'client_request_id' => 'client-2'], + ); + + $client = new SettingsCatalogRestoreGraphClient($policyResponse, [$firstResponse, $secondResponse]); + + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-4', + 'name' => 'Tenant Four', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'scp-4', + 'policy_type' => 'settingsCatalogPolicy', + 'display_name' => 'Settings Catalog Delta', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $payload = [ + 'displayName' => 'Settings Catalog Delta', + 'Settings' => [ + [ + 'id' => 'setting-1', + 'settingInstance' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance', + 'settingDefinitionId' => 'test_setting', + 'simpleSettingValue' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationIntegerSettingValue', + 'value' => 5, + ], + ], + ], + ], + ]; + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => $payload, + ]); + + $user = User::factory()->create(); + $this->actingAs($user); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + )->refresh(); + + expect($run->status)->toBe('completed'); + expect($client->requestCalls)->toHaveCount(2); + expect($client->requestCalls[0]['payload'])->toHaveKey(0); + expect($client->requestCalls[1]['payload'])->toHaveKey('settings'); +}); diff --git a/tests/Unit/GraphContractRegistrySettingsWriteStrategyTest.php b/tests/Unit/GraphContractRegistrySettingsWriteStrategyTest.php index 575a40a..2f848f8 100644 --- a/tests/Unit/GraphContractRegistrySettingsWriteStrategyTest.php +++ b/tests/Unit/GraphContractRegistrySettingsWriteStrategyTest.php @@ -44,6 +44,7 @@ $registry = app(GraphContractRegistry::class); expect($registry->settingsWriteBodyShape('settingsCatalogPolicy'))->toBe('collection'); + expect($registry->settingsWriteFallbackBodyShape('settingsCatalogPolicy'))->toBeNull(); }); it('returns null when settings write contract is missing', function () { @@ -54,3 +55,19 @@ expect($registry->settingsWriteMethod('settingsCatalogPolicy'))->toBeNull(); expect($registry->settingsWritePath('settingsCatalogPolicy', 'policy-1', 'setting-9'))->toBeNull(); }); + +it('returns fallback body shape when configured', function () { + config()->set('graph_contracts.types.settingsCatalogPolicy', [ + 'settings_write' => [ + 'path_template' => 'deviceManagement/configurationPolicies/{id}/settings', + 'method' => 'POST', + 'body_shape' => 'collection', + 'fallback_body_shape' => 'wrapped', + ], + ]); + + $registry = app(GraphContractRegistry::class); + + expect($registry->settingsWriteBodyShape('settingsCatalogPolicy'))->toBe('collection'); + expect($registry->settingsWriteFallbackBodyShape('settingsCatalogPolicy'))->toBe('wrapped'); +});