diff --git a/app/Services/Intune/PolicySnapshotService.php b/app/Services/Intune/PolicySnapshotService.php index 031c561..55ccd51 100644 --- a/app/Services/Intune/PolicySnapshotService.php +++ b/app/Services/Intune/PolicySnapshotService.php @@ -46,6 +46,14 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null 'platform' => $policy->platform, ]; + if ($this->isMetadataOnlyPolicyType($policy->policy_type)) { + $select = $this->metadataOnlySelect($policy->policy_type); + + if ($select !== []) { + $options['select'] = $select; + } + } + if ($policy->policy_type === 'deviceCompliancePolicy') { $options['expand'] = 'scheduledActionsForRule($expand=scheduledActionConfigurations)'; } @@ -110,6 +118,10 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null $metadataWarnings = $response->warnings ?? [$reason]; } + if (! $response->failed() && $this->isMetadataOnlyPolicyType($policy->policy_type)) { + $payload = $this->filterMetadataOnlyPayload($policy->policy_type, is_array($payload) ? $payload : []); + } + $validation = $this->snapshotValidator->validate(is_array($payload) ? $payload : []); $metadataWarnings = array_merge($metadataWarnings, $validation['warnings']); @@ -130,6 +142,55 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null ]; } + private function isMetadataOnlyPolicyType(string $policyType): bool + { + foreach (config('tenantpilot.supported_policy_types', []) as $type) { + if (($type['type'] ?? null) === $policyType) { + return ($type['backup'] ?? null) === 'metadata-only'; + } + } + + return false; + } + + /** + * @return array + */ + private function metadataOnlySelect(string $policyType): array + { + $contract = $this->contracts->get($policyType); + $allowedSelect = $contract['allowed_select'] ?? []; + + if (! is_array($allowedSelect)) { + return []; + } + + return array_values(array_filter( + $allowedSelect, + static fn (mixed $key) => is_string($key) && $key !== '@odata.type' + )); + } + + private function filterMetadataOnlyPayload(string $policyType, array $payload): array + { + $contract = $this->contracts->get($policyType); + $allowedSelect = $contract['allowed_select'] ?? []; + + if (! is_array($allowedSelect) || $allowedSelect === []) { + return $payload; + } + + $filtered = []; + + foreach ($allowedSelect as $key) { + if (is_string($key) && array_key_exists($key, $payload)) { + $filtered[$key] = $payload[$key]; + } + } + + return $filtered; + } + /** * Hydrate settings catalog policies with configuration settings subresource. * diff --git a/config/graph_contracts.php b/config/graph_contracts.php index 774fa8e..f913004 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -323,11 +323,50 @@ 'allowed_expand' => [], 'type_family' => [ '#microsoft.graph.mobileApp', + '#microsoft.graph.androidLobApp', + '#microsoft.graph.androidStoreApp', + '#microsoft.graph.androidManagedStoreApp', + '#microsoft.graph.iosLobApp', + '#microsoft.graph.iosStoreApp', + '#microsoft.graph.iosVppApp', + '#microsoft.graph.winGetApp', + '#microsoft.graph.macOSLobApp', + '#microsoft.graph.macOSMicrosoftEdgeApp', + '#microsoft.graph.macOSMicrosoftDefenderApp', + '#microsoft.graph.macOSDmgApp', + '#microsoft.graph.macOSPkgApp', + '#microsoft.graph.macOsVppApp', + '#microsoft.graph.macOSWebClip', + '#microsoft.graph.managedAndroidLobApp', + '#microsoft.graph.managedAndroidStoreApp', + '#microsoft.graph.managedIOSLobApp', + '#microsoft.graph.managedIOSStoreApp', + '#microsoft.graph.microsoftStoreForBusinessApp', + '#microsoft.graph.officeSuiteApp', + '#microsoft.graph.macOSOfficeSuiteApp', + '#microsoft.graph.webApp', + '#microsoft.graph.windowsWebApp', + '#microsoft.graph.windowsAppX', + '#microsoft.graph.windowsUniversalAppX', + '#microsoft.graph.windowsMicrosoftEdgeApp', + '#microsoft.graph.windowsMobileMSI', + '#microsoft.graph.windowsPhone81AppXBundle', + '#microsoft.graph.windowsPhone81AppX', + '#microsoft.graph.windowsPhone81StoreApp', + '#microsoft.graph.windowsPhoneXAP', + '#microsoft.graph.windowsStoreApp', + '#microsoft.graph.win32LobApp', + '#microsoft.graph.win32CatalogApp', + '#microsoft.graph.iOSiPadOSWebClip', ], 'create_method' => 'POST', 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'assignments_list_path' => '/deviceAppManagement/mobileApps/{id}/assignments', + 'assignments_create_path' => '/deviceAppManagement/mobileApps/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'mobileAppAssignments', ], 'assignmentFilter' => [ 'resource' => 'deviceManagement/assignmentFilters', diff --git a/tests/Unit/GraphContractRegistryActualDataTest.php b/tests/Unit/GraphContractRegistryActualDataTest.php index d032deb..2f7abc6 100644 --- a/tests/Unit/GraphContractRegistryActualDataTest.php +++ b/tests/Unit/GraphContractRegistryActualDataTest.php @@ -102,6 +102,20 @@ ->toContain('scheduledActionsForRule($expand=scheduledActionConfigurations)'); }); +it('exposes mobile app assignment endpoints and type family', function () { + $contract = $this->registry->get('mobileApp'); + + expect($contract)->not->toBeEmpty(); + expect($contract['assignments_list_path'] ?? null) + ->toBe('/deviceAppManagement/mobileApps/{id}/assignments'); + expect($contract['assignments_create_path'] ?? null) + ->toBe('/deviceAppManagement/mobileApps/{id}/assign'); + expect($contract['assignments_payload_key'] ?? null) + ->toBe('mobileAppAssignments'); + expect($this->registry->matchesTypeFamily('mobileApp', '#microsoft.graph.win32LobApp'))->toBeTrue(); + expect($this->registry->matchesTypeFamily('mobileApp', '#microsoft.graph.iosVppApp'))->toBeTrue(); +}); + it('omits role scope tags from assignment filter selects', function () { $contract = $this->registry->get('assignmentFilter'); diff --git a/tests/Unit/PolicySnapshotServiceTest.php b/tests/Unit/PolicySnapshotServiceTest.php index 93cbed1..2f73057 100644 --- a/tests/Unit/PolicySnapshotServiceTest.php +++ b/tests/Unit/PolicySnapshotServiceTest.php @@ -24,6 +24,22 @@ public function getPolicy(string $policyType, string $policyId, array $options = { $this->requests[] = ['getPolicy', $policyType, $policyId, $options]; + if ($policyType === 'mobileApp') { + return new GraphResponse(success: true, data: [ + 'payload' => [ + 'id' => $policyId, + 'displayName' => 'Contoso Portal', + 'publisher' => 'Contoso', + 'description' => 'Company Portal', + '@odata.type' => '#microsoft.graph.win32LobApp', + 'createdDateTime' => '2025-01-01T00:00:00Z', + 'lastModifiedDateTime' => '2025-01-02T00:00:00Z', + 'installCommandLine' => 'setup.exe /quiet', + 'largeIcon' => ['type' => 'image/png', 'value' => '...'], + ], + ]); + } + return new GraphResponse(success: true, data: [ 'payload' => [ 'id' => $policyId, @@ -107,3 +123,45 @@ public function request(string $method, string $path, array $options = []): Grap expect($client->requests[0][3]['expand'] ?? null) ->toBe('scheduledActionsForRule($expand=scheduledActionConfigurations)'); }); + +it('filters mobile app snapshots to metadata-only keys', function () { + $client = new PolicySnapshotGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-apps', + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret-123', + 'is_current' => true, + ]); + $tenant->makeCurrent(); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'app-123', + 'policy_type' => 'mobileApp', + 'display_name' => 'Contoso Portal', + 'platform' => 'all', + ]); + + $service = app(PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result['payload'])->toHaveKeys([ + 'id', + 'displayName', + 'publisher', + 'description', + '@odata.type', + 'createdDateTime', + 'lastModifiedDateTime', + ]); + expect($result['payload'])->not->toHaveKey('installCommandLine'); + expect($result['payload'])->not->toHaveKey('largeIcon'); + expect($client->requests[0][0])->toBe('getPolicy'); + expect($client->requests[0][1])->toBe('mobileApp'); + expect($client->requests[0][2])->toBe('app-123'); + expect($client->requests[0][3]['select'] ?? null)->toBeArray(); + expect($client->requests[0][3]['select'])->toContain('displayName'); + expect($client->requests[0][3]['select'])->not->toContain('@odata.type'); +});