From 5595bb52b6801d763951cfcf455f701db53c610e Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sat, 3 Jan 2026 20:52:27 +0100 Subject: [PATCH] fix: harden Graph endpoint resolution --- app/Services/Graph/MicrosoftGraphClient.php | 23 +++- app/Services/Intune/RestoreService.php | 6 ++ .../entries/restore-results.blade.php | 8 +- .../Feature/RestoreGraphErrorMetadataTest.php | 102 ++++++++++++++++++ .../GraphClientEndpointResolutionTest.php | 37 +++++++ 5 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/RestoreGraphErrorMetadataTest.php diff --git a/app/Services/Graph/MicrosoftGraphClient.php b/app/Services/Graph/MicrosoftGraphClient.php index 3f47bab..094fe16 100644 --- a/app/Services/Graph/MicrosoftGraphClient.php +++ b/app/Services/Graph/MicrosoftGraphClient.php @@ -781,8 +781,17 @@ private function endpointFor(string $policyType): string return $contractResource; } - $supported = config('tenantpilot.supported_policy_types', []); - foreach ($supported as $type) { + $builtinEndpoint = $this->builtinEndpointFor($policyType); + if ($builtinEndpoint !== null) { + return $builtinEndpoint; + } + + $types = array_merge( + config('tenantpilot.supported_policy_types', []), + config('tenantpilot.foundation_types', []), + ); + + foreach ($types as $type) { if (($type['type'] ?? null) === $policyType && ! empty($type['endpoint'])) { return $type['endpoint']; } @@ -791,6 +800,16 @@ private function endpointFor(string $policyType): string return 'deviceManagement/'.$policyType; } + private function builtinEndpointFor(string $policyType): ?string + { + return match ($policyType) { + 'settingsCatalogPolicy', + 'endpointSecurityPolicy', + 'securityBaselinePolicy' => 'deviceManagement/configurationPolicies', + default => null, + }; + } + private function getAccessToken(array $context): string { $tenant = $context['tenant'] ?? $this->tenantId; diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index 0fb6cc8..b62ed49 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -676,6 +676,8 @@ public function execute( 'graph_error_code' => $response->meta['error_code'] ?? null, 'graph_request_id' => $response->meta['request_id'] ?? null, 'graph_client_request_id' => $response->meta['client_request_id'] ?? null, + 'graph_method' => $response->meta['method'] ?? null, + 'graph_path' => $response->meta['path'] ?? null, ]; $hardFailures++; @@ -982,6 +984,10 @@ private function isNotFoundResponse(object $response): bool $code = strtolower((string) ($response->meta['error_code'] ?? '')); $message = strtolower((string) ($response->meta['error_message'] ?? '')); + if ($message !== '' && str_contains($message, 'resource not found for the segment')) { + return false; + } + if ($code !== '' && (str_contains($code, 'notfound') || str_contains($code, 'resource'))) { return true; } diff --git a/resources/views/filament/infolists/entries/restore-results.blade.php b/resources/views/filament/infolists/entries/restore-results.blade.php index 38a6ce4..0c9e694 100644 --- a/resources/views/filament/infolists/entries/restore-results.blade.php +++ b/resources/views/filament/infolists/entries/restore-results.blade.php @@ -268,10 +268,16 @@ @if (! empty($item['graph_error_code']))
Code: {{ $item['graph_error_code'] }}
@endif - @if (! empty($item['graph_request_id']) || ! empty($item['graph_client_request_id'])) + @if (! empty($item['graph_request_id']) || ! empty($item['graph_client_request_id']) || ! empty($item['graph_method']) || ! empty($item['graph_path']))
Details
+ @if (! empty($item['graph_method'])) +
method: {{ $item['graph_method'] }}
+ @endif + @if (! empty($item['graph_path'])) +
path: {{ $item['graph_path'] }}
+ @endif @if (! empty($item['graph_request_id']))
request-id: {{ $item['graph_request_id'] }}
@endif diff --git a/tests/Feature/RestoreGraphErrorMetadataTest.php b/tests/Feature/RestoreGraphErrorMetadataTest.php new file mode 100644 index 0000000..0222ca2 --- /dev/null +++ b/tests/Feature/RestoreGraphErrorMetadataTest.php @@ -0,0 +1,102 @@ +}> */ + public array $applyPolicyCalls = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, ['payload' => []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->applyPolicyCalls[] = [ + 'policyType' => $policyType, + 'policyId' => $policyId, + 'payload' => $payload, + 'options' => $options, + ]; + + return new GraphResponse(false, ['error' => ['message' => 'Bad request']], 400, [], [], [ + 'error_code' => 'BadRequest', + 'error_message' => "Resource not found for the segment 'endpointSecurityPolicy'.", + 'request_id' => 'req-1', + 'client_request_id' => 'client-1', + 'method' => 'PATCH', + 'path' => 'deviceManagement/endpointSecurityPolicy/esp-1', + ]); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } +} + +test('restore results include graph path and method on Graph failures', function () { + $client = new RestoreGraphErrorMetadataGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([ + 'policy_id' => null, + 'policy_identifier' => 'esp-1', + 'policy_type' => 'endpointSecurityPolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'esp-1', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'name' => 'Endpoint Security Policy', + 'settings' => [], + ], + 'assignments' => null, + ]); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + ); + + expect($client->applyPolicyCalls)->toHaveCount(1); + expect($run->status)->toBe('failed'); + + $result = $run->results[0] ?? null; + expect($result)->toBeArray(); + expect($result['graph_method'] ?? null)->toBe('PATCH'); + expect($result['graph_path'] ?? null)->toBe('deviceManagement/endpointSecurityPolicy/esp-1'); +}); diff --git a/tests/Unit/GraphClientEndpointResolutionTest.php b/tests/Unit/GraphClientEndpointResolutionTest.php index 3c76a8a..532b799 100644 --- a/tests/Unit/GraphClientEndpointResolutionTest.php +++ b/tests/Unit/GraphClientEndpointResolutionTest.php @@ -61,3 +61,40 @@ return str_contains($request->url(), '/beta/deviceAppManagement/targetedManagedAppConfigurations/A_1'); }); }); + +it('uses built-in endpoint mapping for endpoint security policies when config is missing', function () { + config()->set('graph_contracts.types.endpointSecurityPolicy', []); + config()->set('tenantpilot.foundation_types', []); + + Http::fake([ + 'https://login.microsoftonline.com/*' => Http::response([ + 'access_token' => 'fake-token', + 'expires_in' => 3600, + ], 200), + 'https://graph.microsoft.com/*' => Http::response(['id' => 'E_1'], 200), + ]); + + $client = new MicrosoftGraphClient( + logger: app(GraphLogger::class), + contracts: app(GraphContractRegistry::class), + ); + + $client->applyPolicy( + policyType: 'endpointSecurityPolicy', + policyId: 'E_1', + payload: ['name' => 'Test'], + options: ['tenant' => 'tenant', 'client_id' => 'client', 'client_secret' => 'secret'], + ); + + Http::assertSent(function (Request $request) { + if (! str_contains($request->url(), 'graph.microsoft.com')) { + return false; + } + + if (! str_contains($request->url(), '/beta/deviceManagement/configurationPolicies/E_1')) { + return false; + } + + return ! str_contains($request->url(), '/beta/deviceManagement/endpointSecurityPolicy/E_1'); + }); +});