diff --git a/app/Console/Commands/ReclassifyEnrollmentConfigurations.php b/app/Console/Commands/ReclassifyEnrollmentConfigurations.php index a7bf0c5..ce0c783 100644 --- a/app/Console/Commands/ReclassifyEnrollmentConfigurations.php +++ b/app/Console/Commands/ReclassifyEnrollmentConfigurations.php @@ -5,6 +5,7 @@ use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\Tenant; +use App\Services\Graph\GraphClientInterface; use Illuminate\Console\Command; class ReclassifyEnrollmentConfigurations extends Command @@ -26,13 +27,19 @@ class ReclassifyEnrollmentConfigurations extends Command /** * Execute the console command. */ + public function __construct(private readonly GraphClientInterface $graphClient) + { + parent::__construct(); + } + public function handle(): int { $tenant = $this->resolveTenantOrNull(); $dryRun = ! (bool) $this->option('write'); - $query = PolicyVersion::query() - ->with('policy') + $query = Policy::query() + ->with(['tenant']) + ->active() ->where('policy_type', 'enrollmentRestriction'); if ($tenant) { @@ -43,52 +50,103 @@ public function handle(): int $changedVersions = 0; $changedPolicies = 0; + $ignoredPolicies = 0; - foreach ($candidates as $version) { - $snapshot = is_array($version->snapshot) ? $version->snapshot : []; - $odataType = $snapshot['@odata.type'] ?? null; - $configurationType = $snapshot['deviceEnrollmentConfigurationType'] ?? null; + foreach ($candidates as $policy) { + $latestVersion = $policy->versions()->latest('version_number')->first(); + $snapshot = $latestVersion?->snapshot; - $isEsp = (is_string($odataType) && strcasecmp($odataType, '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration') === 0) - || (is_string($configurationType) && $configurationType === 'windows10EnrollmentCompletionPageConfiguration'); + if (! is_array($snapshot)) { + $snapshot = $this->fetchSnapshotOrNull($policy); + } - if (! $isEsp) { + if (! is_array($snapshot)) { + continue; + } + + if (! $this->isEspSnapshot($snapshot)) { continue; } $this->line(sprintf( - 'ESP detected: policy_version=%s policy=%s tenant_id=%s', - (string) $version->getKey(), - $version->policy_id ? (string) $version->policy_id : 'n/a', - (string) $version->tenant_id, + 'ESP detected: policy=%s tenant_id=%s external_id=%s', + (string) $policy->getKey(), + (string) $policy->tenant_id, + (string) $policy->external_id, )); if ($dryRun) { continue; } - $version->forceFill([ - 'policy_type' => 'windowsEnrollmentStatusPage', - 'platform' => $version->platform ?: 'all', - ])->save(); - $changedVersions++; + $existingTarget = Policy::query() + ->where('tenant_id', $policy->tenant_id) + ->where('external_id', $policy->external_id) + ->where('policy_type', 'windowsEnrollmentStatusPage') + ->first(); - if ($version->policy instanceof Policy && $version->policy->policy_type === 'enrollmentRestriction') { - $version->policy->forceFill([ - 'policy_type' => 'windowsEnrollmentStatusPage', - ])->save(); - $changedPolicies++; + if ($existingTarget) { + $policy->forceFill(['ignored_at' => now()])->save(); + $ignoredPolicies++; + + continue; } + + $policy->forceFill([ + 'policy_type' => 'windowsEnrollmentStatusPage', + ])->save(); + $changedPolicies++; + + $changedVersions += PolicyVersion::query() + ->where('policy_id', $policy->id) + ->where('policy_type', 'enrollmentRestriction') + ->update(['policy_type' => 'windowsEnrollmentStatusPage']); } $this->info('Done.'); $this->info('PolicyVersions changed: '.$changedVersions); $this->info('Policies changed: '.$changedPolicies); + $this->info('Policies ignored: '.$ignoredPolicies); $this->info('Mode: '.($dryRun ? 'dry-run' : 'write')); return Command::SUCCESS; } + private function isEspSnapshot(array $snapshot): bool + { + $odataType = $snapshot['@odata.type'] ?? null; + $configurationType = $snapshot['deviceEnrollmentConfigurationType'] ?? null; + + return (is_string($odataType) && strcasecmp($odataType, '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration') === 0) + || (is_string($configurationType) && $configurationType === 'windows10EnrollmentCompletionPageConfiguration'); + } + + private function fetchSnapshotOrNull(Policy $policy): ?array + { + $tenant = $policy->tenant; + + if (! $tenant) { + return null; + } + + $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; + + $response = $this->graphClient->getPolicy('enrollmentRestriction', $policy->external_id, [ + 'tenant' => $tenantIdentifier, + 'client_id' => $tenant->app_client_id, + 'client_secret' => $tenant->app_client_secret, + 'platform' => $policy->platform, + ]); + + if ($response->failed()) { + return null; + } + + $payload = $response->data['payload'] ?? null; + + return is_array($payload) ? $payload : null; + } + private function resolveTenantOrNull(): ?Tenant { $tenantOption = $this->option('tenant'); diff --git a/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php b/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php index f84777c..9177392 100644 --- a/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php +++ b/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php @@ -3,6 +3,7 @@ namespace App\Services\Intune; use Illuminate\Support\Arr; +use Illuminate\Support\Str; class EnrollmentAutopilotPolicyNormalizer implements PolicyTypeNormalizer { @@ -110,7 +111,11 @@ private function buildAutopilotBlock(array $snapshot): ?array $oobe = Arr::get($snapshot, 'outOfBoxExperienceSettings'); if (is_array($oobe) && $oobe !== []) { - $entries[] = ['key' => 'Out-of-box experience', 'value' => Arr::except($oobe, ['@odata.type'])]; + $oobe = Arr::except($oobe, ['@odata.type']); + + foreach ($this->expandOutOfBoxExperienceEntries($oobe) as $entry) { + $entries[] = $entry; + } } $assignments = Arr::get($snapshot, 'assignments'); @@ -129,6 +134,58 @@ private function buildAutopilotBlock(array $snapshot): ?array ]; } + /** + * @return array + */ + private function expandOutOfBoxExperienceEntries(array $oobe): array + { + $knownKeys = [ + 'hideEULA' => 'Hide EULA', + 'userType' => 'User type', + 'hideEscapeLink' => 'Hide escape link', + 'deviceUsageType' => 'Device usage type', + 'hidePrivacySettings' => 'Hide privacy settings', + 'skipKeyboardSelectionPage' => 'Skip keyboard selection page', + 'skipExpressSettings' => 'Skip express settings', + ]; + + $entries = []; + + foreach ($knownKeys as $key => $label) { + if (! array_key_exists($key, $oobe)) { + continue; + } + + $value = $oobe[$key]; + + if (is_bool($value)) { + $entries[] = ['key' => "OOBE: {$label}", 'value' => $value ? 'Enabled' : 'Disabled']; + } elseif (is_string($value) && $value !== '') { + $entries[] = ['key' => "OOBE: {$label}", 'value' => $value]; + } elseif (is_int($value) || is_float($value)) { + $entries[] = ['key' => "OOBE: {$label}", 'value' => $value]; + } + + unset($oobe[$key]); + } + + foreach ($oobe as $key => $value) { + $label = Str::headline((string) $key); + + if (is_bool($value)) { + $entries[] = ['key' => "OOBE: {$label}", 'value' => $value ? 'Enabled' : 'Disabled']; + } elseif (is_string($value) && $value !== '') { + $entries[] = ['key' => "OOBE: {$label}", 'value' => $value]; + } elseif (is_int($value) || is_float($value)) { + $entries[] = ['key' => "OOBE: {$label}", 'value' => $value]; + } elseif (is_array($value) && $value !== []) { + $entries[] = ['key' => "OOBE: {$label}", 'value' => $value]; + } + } + + return $entries; + } + /** * @return array{type: string, title: string, entries: array}|null */ @@ -197,9 +254,53 @@ private function buildEnrollmentRestrictionBlock(array $snapshot): ?array } } - $platforms = Arr::get($snapshot, 'platformRestrictions'); - if (is_array($platforms) && $platforms !== []) { - $entries[] = ['key' => 'Platform restrictions', 'value' => Arr::except($platforms, ['@odata.type'])]; + $platformRestrictions = Arr::get($snapshot, 'platformRestrictions'); + $platformRestriction = Arr::get($snapshot, 'platformRestriction'); + + $platformPayload = is_array($platformRestrictions) && $platformRestrictions !== [] + ? $platformRestrictions + : (is_array($platformRestriction) ? $platformRestriction : null); + + if (is_array($platformPayload) && $platformPayload !== []) { + $platformPayload = Arr::except($platformPayload, ['@odata.type']); + + $platformBlocked = Arr::get($platformPayload, 'platformBlocked'); + if (is_bool($platformBlocked)) { + $entries[] = ['key' => 'Platform blocked', 'value' => $platformBlocked ? 'Enabled' : 'Disabled']; + } + + $personalBlocked = Arr::get($platformPayload, 'personalDeviceEnrollmentBlocked'); + if (is_bool($personalBlocked)) { + $entries[] = ['key' => 'Personal device enrollment blocked', 'value' => $personalBlocked ? 'Enabled' : 'Disabled']; + } + + $osMin = Arr::get($platformPayload, 'osMinimumVersion'); + $entries[] = [ + 'key' => 'OS minimum version', + 'value' => (is_string($osMin) && $osMin !== '') ? $osMin : 'None', + ]; + + $osMax = Arr::get($platformPayload, 'osMaximumVersion'); + $entries[] = [ + 'key' => 'OS maximum version', + 'value' => (is_string($osMax) && $osMax !== '') ? $osMax : 'None', + ]; + + $blockedManufacturers = Arr::get($platformPayload, 'blockedManufacturers'); + $entries[] = [ + 'key' => 'Blocked manufacturers', + 'value' => (is_array($blockedManufacturers) && $blockedManufacturers !== []) + ? array_values($blockedManufacturers) + : ['None'], + ]; + + $blockedSkus = Arr::get($platformPayload, 'blockedSkus'); + $entries[] = [ + 'key' => 'Blocked SKUs', + 'value' => (is_array($blockedSkus) && $blockedSkus !== []) + ? array_values($blockedSkus) + : ['None'], + ]; } $assigned = Arr::get($snapshot, 'assignments'); diff --git a/app/Services/Intune/PolicySyncService.php b/app/Services/Intune/PolicySyncService.php index 3d4fc06..cf08815 100644 --- a/app/Services/Intune/PolicySyncService.php +++ b/app/Services/Intune/PolicySyncService.php @@ -3,6 +3,7 @@ namespace App\Services\Intune; use App\Models\Policy; +use App\Models\PolicyVersion; use App\Models\Tenant; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphErrorMapper; @@ -78,6 +79,12 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr continue; } + $canonicalPolicyType = $this->resolveCanonicalPolicyType($policyType, $policyData); + + if ($canonicalPolicyType !== $policyType) { + continue; + } + if ($policyType === 'appProtectionPolicy') { $odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null; @@ -96,15 +103,11 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr $displayName = $policyData['displayName'] ?? $policyData['name'] ?? 'Unnamed policy'; $policyPlatform = $platform ?? ($policyData['platform'] ?? null); - $existingWithDifferentType = Policy::query() - ->where('tenant_id', $tenant->id) - ->where('external_id', $externalId) - ->where('policy_type', '!=', $policyType) - ->exists(); - - if ($existingWithDifferentType) { - continue; - } + $this->reclassifyEnrollmentConfigurationPoliciesIfNeeded( + tenantId: $tenant->id, + externalId: $externalId, + policyType: $policyType, + ); $policy = Policy::updateOrCreate( [ @@ -128,6 +131,106 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr return $synced; } + private function resolveCanonicalPolicyType(string $policyType, array $policyData): string + { + if (! in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) { + return $policyType; + } + + if ($this->isEnrollmentStatusPageItem($policyData)) { + return 'windowsEnrollmentStatusPage'; + } + + if ($this->isEnrollmentRestrictionItem($policyData)) { + return 'enrollmentRestriction'; + } + + return $policyType; + } + + private function isEnrollmentStatusPageItem(array $policyData): bool + { + $odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null; + $configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null; + + return (is_string($odataType) && strcasecmp($odataType, '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration') === 0) + || (is_string($configurationType) && $configurationType === 'windows10EnrollmentCompletionPageConfiguration'); + } + + private function isEnrollmentRestrictionItem(array $policyData): bool + { + $odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null; + $configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null; + + $restrictionOdataTypes = [ + '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration', + '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration', + '#microsoft.graph.deviceEnrollmentLimitConfiguration', + ]; + + if (is_string($odataType)) { + foreach ($restrictionOdataTypes as $expected) { + if (strcasecmp($odataType, $expected) === 0) { + return true; + } + } + } + + return is_string($configurationType) + && in_array($configurationType, [ + 'deviceEnrollmentPlatformRestrictionConfiguration', + 'deviceEnrollmentPlatformRestrictionsConfiguration', + 'deviceEnrollmentLimitConfiguration', + ], true); + } + + private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void + { + if (! in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) { + return; + } + + $enrollmentTypes = ['enrollmentRestriction', 'windowsEnrollmentStatusPage']; + + $existingCorrect = Policy::query() + ->where('tenant_id', $tenantId) + ->where('external_id', $externalId) + ->where('policy_type', $policyType) + ->first(); + + if ($existingCorrect) { + Policy::query() + ->where('tenant_id', $tenantId) + ->where('external_id', $externalId) + ->whereIn('policy_type', $enrollmentTypes) + ->where('policy_type', '!=', $policyType) + ->whereNull('ignored_at') + ->update(['ignored_at' => now()]); + + return; + } + + $existingWrong = Policy::query() + ->where('tenant_id', $tenantId) + ->where('external_id', $externalId) + ->whereIn('policy_type', $enrollmentTypes) + ->where('policy_type', '!=', $policyType) + ->whereNull('ignored_at') + ->first(); + + if (! $existingWrong) { + return; + } + + $existingWrong->forceFill([ + 'policy_type' => $policyType, + ])->save(); + + PolicyVersion::query() + ->where('policy_id', $existingWrong->id) + ->update(['policy_type' => $policyType]); + } + /** * Re-fetch a single policy from Graph and update local metadata. */ diff --git a/config/graph_contracts.php b/config/graph_contracts.php index 2b877e9..eb9bd59 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -340,8 +340,9 @@ 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'], 'allowed_expand' => [], 'type_family' => [ - '#microsoft.graph.deviceEnrollmentConfiguration', - '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration', + '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration', + '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration', + '#microsoft.graph.deviceEnrollmentLimitConfiguration', ], 'create_method' => 'POST', 'update_method' => 'PATCH', diff --git a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php index 95bfd3c..015fd5d 100644 --- a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php +++ b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php @@ -1,6 +1,5 @@ @php use Illuminate\Support\Str; - use Illuminate\Support\Js; // Extract state from Filament ViewEntry $state = $getState(); @@ -23,7 +22,9 @@ } if (is_array($value)) { - return Js::from($value)->toHtml(); + $encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + return is_string($encoded) ? $encoded : 'N/A'; } if (is_object($value)) { @@ -31,11 +32,31 @@ return (string) $value; } - return Js::from((array) $value)->toHtml(); + $encoded = json_encode((array) $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + return is_string($encoded) ? $encoded : 'N/A'; } return 'N/A'; }; + + $shouldRenderBadges = function (mixed $value): bool { + if (! is_array($value) || $value === []) { + return false; + } + + if (! array_is_list($value)) { + return false; + } + + foreach ($value as $item) { + if (! is_scalar($item) && ! is_null($item)) { + return false; + } + } + + return true; + }; @endphp
@@ -123,6 +144,14 @@ {{ $row['value'] }} + @elseif($shouldRenderBadges($row['value'] ?? null)) +
+ @foreach(($row['value'] ?? []) as $item) + + {{ is_bool($item) ? ($item ? 'Enabled' : 'Disabled') : (string) $item }} + + @endforeach +
@else {{ Str::limit($stringifyValue($row['value'] ?? null), 200) }} @@ -152,9 +181,19 @@ {{ $entry['key'] }}
- - {{ Str::limit($stringifyValue($entry['value'] ?? null), 200) }} - + @if($shouldRenderBadges($entry['value'] ?? null)) +
+ @foreach(($entry['value'] ?? []) as $item) + + {{ is_bool($item) ? ($item ? 'Enabled' : 'Disabled') : (string) $item }} + + @endforeach +
+ @else + + {{ Str::limit($stringifyValue($entry['value'] ?? null), 200) }} + + @endif
@endforeach diff --git a/specs/014-enrollment-autopilot/tasks.md b/specs/014-enrollment-autopilot/tasks.md index 481bb85..0d507a1 100644 --- a/specs/014-enrollment-autopilot/tasks.md +++ b/specs/014-enrollment-autopilot/tasks.md @@ -20,11 +20,14 @@ ## Phase 2: UI Normalization ## Phase 3: Restore Safety - [x] T005 Add a feature test verifying `enrollmentRestriction` restore is preview-only and skipped on execution (no Graph apply calls). +## Phase 3b: Enrollment Configuration Type Collisions +- [x] T005b Fix ESP vs enrollment restriction collision on `deviceEnrollmentConfigurations` sync (canonical type resolution + safe reclassification). + ## Phase 4: Tests + Verification -- [ ] T006 Add unit tests for normalized output (shape + stability) for the three policy types. -- [ ] T007 Add Filament render tests for “Normalized settings” tab for the three policy types. -- [ ] T008 Run targeted tests. -- [ ] T009 Run Pint (`./vendor/bin/pint --dirty`). +- [x] T006 Add unit tests for normalized output (shape + stability) for the three policy types. +- [x] T007 Add Filament render tests for “Normalized settings” tab for the three policy types. +- [x] T008 Run targeted tests. +- [x] T009 Run Pint (`./vendor/bin/pint --dirty`). ## Open TODOs (Follow-up) - None. diff --git a/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php b/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php new file mode 100644 index 0000000..a35ada5 --- /dev/null +++ b/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php @@ -0,0 +1,151 @@ + 'local-tenant', + 'name' => 'Tenant One', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + $this->tenant = $tenant; + $this->user = User::factory()->create(); +}); + +test('policy detail renders normalized settings for Autopilot profiles', function () { + $policy = Policy::create([ + 'tenant_id' => $this->tenant->id, + 'external_id' => 'autopilot-1', + 'policy_type' => 'windowsAutopilotDeploymentProfile', + 'display_name' => 'Autopilot Profile A', + 'platform' => 'windows', + ]); + + PolicyVersion::create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'created_by' => 'tester@example.com', + 'captured_at' => CarbonImmutable::now(), + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile', + 'displayName' => 'Autopilot Profile A', + 'deviceNameTemplate' => 'DEV-%SERIAL%', + 'enableWhiteGlove' => true, + 'outOfBoxExperienceSettings' => [ + 'hideEULA' => true, + 'userType' => 'standard', + ], + ], + ]); + + $response = $this->actingAs($this->user) + ->get(PolicyResource::getUrl('view', ['record' => $policy])); + + $response->assertOk(); + $response->assertSee('Settings'); + $response->assertSee('Autopilot profile'); + $response->assertSee('Device name template'); + $response->assertSee('DEV-%SERIAL%'); + $response->assertSee('Pre-provisioning (White Glove)'); + $response->assertSee('Enabled'); + $response->assertSee('OOBE: Hide EULA'); + $response->assertSee('OOBE: User type'); +}); + +test('policy detail renders normalized settings for Enrollment Status Page (ESP)', function () { + $policy = Policy::create([ + 'tenant_id' => $this->tenant->id, + 'external_id' => 'esp-1', + 'policy_type' => 'windowsEnrollmentStatusPage', + 'display_name' => 'ESP A', + 'platform' => 'windows', + ]); + + PolicyVersion::create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'created_by' => 'tester@example.com', + 'captured_at' => CarbonImmutable::now(), + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.windowsEnrollmentStatusPageConfiguration', + 'displayName' => 'ESP A', + 'priority' => 1, + 'showInstallationProgress' => true, + 'installProgressTimeoutInMinutes' => 60, + 'selectedMobileAppIds' => ['app-1', 'app-2'], + ], + ]); + + $response = $this->actingAs($this->user) + ->get(PolicyResource::getUrl('view', ['record' => $policy])); + + $response->assertOk(); + $response->assertSee('Settings'); + $response->assertSee('Enrollment Status Page (ESP)'); + $response->assertSee('Priority'); + $response->assertSee('1'); + $response->assertSee('Show installation progress'); + $response->assertSee('Enabled'); + $response->assertSee('Selected mobile app IDs'); + $response->assertSee('app-1'); + $response->assertSee('app-2'); +}); + +test('policy detail renders normalized settings for enrollment restrictions', function () { + $policy = Policy::create([ + 'tenant_id' => $this->tenant->id, + 'external_id' => 'enroll-restrict-1', + 'policy_type' => 'enrollmentRestriction', + 'display_name' => 'Restriction A', + 'platform' => 'all', + ]); + + PolicyVersion::create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'created_by' => 'tester@example.com', + 'captured_at' => CarbonImmutable::now(), + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration', + 'displayName' => 'Restriction A', + 'deviceEnrollmentConfigurationType' => 'deviceEnrollmentPlatformRestrictionConfiguration', + 'platformRestriction' => [ + 'platformBlocked' => false, + 'personalDeviceEnrollmentBlocked' => true, + 'blockedSkus' => ['sku-1'], + ], + ], + ]); + + $response = $this->actingAs($this->user) + ->get(PolicyResource::getUrl('view', ['record' => $policy])); + + $response->assertOk(); + $response->assertSee('Settings'); + $response->assertSee('Enrollment restrictions'); + $response->assertSee('Personal device enrollment blocked'); + $response->assertSee('Enabled'); + $response->assertSee('Blocked SKUs'); + $response->assertSee('sku-1'); +}); diff --git a/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php b/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php index 6a2bb75..566c323 100644 --- a/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php +++ b/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php @@ -55,4 +55,9 @@ $response->assertOk(); $response->assertSee('Settings'); $response->assertSee('Scope tag IDs'); + $response->assertSee('0'); + $response->assertSee('1'); + $response->assertSee('OOBE: Hide EULA'); + $response->assertSee('OOBE: User type'); + $response->assertSee('standard'); }); diff --git a/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php b/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php new file mode 100644 index 0000000..a311e43 --- /dev/null +++ b/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php @@ -0,0 +1,73 @@ + 'tenant-sync-collision', + 'name' => 'Tenant Sync Collision', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + // Simulate an older bug: ESP row was synced under enrollmentRestriction. + $wrong = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'esp-1', + 'policy_type' => 'enrollmentRestriction', + 'display_name' => 'ESP Misclassified', + 'platform' => 'all', + ]); + + $this->mock(GraphClientInterface::class, function (MockInterface $mock) { + $espPayload = [ + 'id' => 'esp-1', + 'displayName' => 'Enrollment Status Page', + '@odata.type' => '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration', + 'deviceEnrollmentConfigurationType' => 'windows10EnrollmentCompletionPageConfiguration', + ]; + + $mock->shouldReceive('listPolicies') + ->andReturnUsing(function (string $policyType) use ($espPayload) { + if ($policyType === 'enrollmentRestriction') { + // Shared endpoint can return ESP items if unfiltered. + return new GraphResponse(true, [$espPayload]); + } + + if ($policyType === 'windowsEnrollmentStatusPage') { + return new GraphResponse(true, [$espPayload]); + } + + return new GraphResponse(true, []); + }); + }); + + $service = app(PolicySyncService::class); + + $service->syncPolicies($tenant, [ + [ + 'type' => 'enrollmentRestriction', + 'platform' => 'all', + 'filter' => null, + ], + [ + 'type' => 'windowsEnrollmentStatusPage', + 'platform' => 'all', + 'filter' => null, + ], + ]); + + $wrong->refresh(); + + expect($wrong->policy_type)->toBe('windowsEnrollmentStatusPage'); +}); diff --git a/tests/Feature/ReclassifyEnrollmentConfigurationsCommandTest.php b/tests/Feature/ReclassifyEnrollmentConfigurationsCommandTest.php index d2bd58a..5186cdb 100644 --- a/tests/Feature/ReclassifyEnrollmentConfigurationsCommandTest.php +++ b/tests/Feature/ReclassifyEnrollmentConfigurationsCommandTest.php @@ -3,8 +3,11 @@ use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\Tenant; +use App\Services\Graph\GraphClientInterface; +use App\Services\Graph\GraphResponse; use Carbon\CarbonImmutable; use Illuminate\Foundation\Testing\RefreshDatabase; +use Mockery\MockInterface; uses(RefreshDatabase::class); @@ -59,3 +62,45 @@ expect($version->policy_type)->toBe('windowsEnrollmentStatusPage'); expect($policy->policy_type)->toBe('windowsEnrollmentStatusPage'); }); + +test('reclassify command can detect ESP even when a policy has no versions', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-reclassify-no-versions', + 'name' => 'Tenant Reclassify (No Versions)', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'esp-2', + 'policy_type' => 'enrollmentRestriction', + 'display_name' => 'ESP Misclassified (No Versions)', + 'platform' => 'all', + ]); + + $this->mock(GraphClientInterface::class, function (MockInterface $mock) { + $mock->shouldReceive('getPolicy') + ->andReturn(new GraphResponse(true, [ + 'payload' => [ + '@odata.type' => '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration', + 'deviceEnrollmentConfigurationType' => 'windows10EnrollmentCompletionPageConfiguration', + 'displayName' => 'ESP Misclassified (No Versions)', + ], + ])); + }); + + $this->artisan('intune:reclassify-enrollment-configurations', ['--tenant' => $tenant->tenant_id]) + ->assertSuccessful(); + + $policy->refresh(); + expect($policy->policy_type)->toBe('enrollmentRestriction'); + + $this->artisan('intune:reclassify-enrollment-configurations', ['--tenant' => $tenant->tenant_id, '--write' => true]) + ->assertSuccessful(); + + $policy->refresh(); + expect($policy->policy_type)->toBe('windowsEnrollmentStatusPage'); +}); diff --git a/tests/Unit/PolicyNormalizerTest.php b/tests/Unit/PolicyNormalizerTest.php index 3685f2d..9a7dccf 100644 --- a/tests/Unit/PolicyNormalizerTest.php +++ b/tests/Unit/PolicyNormalizerTest.php @@ -66,3 +66,95 @@ expect(collect($result['warnings'])->join(' '))->toContain('@odata.type mismatch'); }); + +it('normalizes enrollment restrictions platform restriction payload', function () { + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration', + 'deviceEnrollmentConfigurationType' => 'deviceEnrollmentPlatformRestrictionConfiguration', + 'displayName' => 'DeviceTypeRestriction', + 'version' => 2, + // Graph uses this singular shape for platform restriction configs. + 'platformRestriction' => [ + 'platformBlocked' => false, + 'personalDeviceEnrollmentBlocked' => true, + ], + ]; + + $result = $this->normalizer->normalize($snapshot, 'enrollmentRestriction', 'all'); + + $block = collect($result['settings'])->firstWhere('title', 'Enrollment restrictions'); + expect($block)->not->toBeNull(); + + $platformEntry = collect($block['entries'] ?? [])->firstWhere('key', 'Platform restrictions'); + expect($platformEntry)->toBeNull(); + + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform blocked')['value'] ?? null)->toBe('Disabled'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Personal device enrollment blocked')['value'] ?? null)->toBe('Enabled'); + + expect(collect($block['entries'] ?? [])->firstWhere('key', 'OS minimum version')['value'] ?? null)->toBe('None'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'OS maximum version')['value'] ?? null)->toBe('None'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Blocked manufacturers')['value'] ?? null)->toBe(['None']); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Blocked SKUs')['value'] ?? null)->toBe(['None']); +}); + +it('normalizes Autopilot deployment profile key fields', function () { + $snapshot = [ + '@odata.type' => '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile', + 'displayName' => 'Autopilot Profile A', + 'description' => 'Used for standard devices', + 'deviceNameTemplate' => 'DEV-%SERIAL%', + 'deploymentMode' => 'singleUser', + 'deviceType' => 'windowsPc', + 'enableWhiteGlove' => true, + 'outOfBoxExperienceSettings' => [ + 'hideEULA' => true, + 'userType' => 'standard', + ], + ]; + + $result = $this->normalizer->normalize($snapshot, 'windowsAutopilotDeploymentProfile', 'windows'); + + expect($result['status'])->toBe('ok'); + expect($result['warnings'])->toBe([]); + + $general = collect($result['settings'])->firstWhere('title', 'General'); + expect($general)->not->toBeNull(); + expect(collect($general['entries'] ?? [])->firstWhere('key', 'Type')['value'] ?? null)->toBe('windowsAutopilotDeploymentProfile'); + expect(collect($general['entries'] ?? [])->firstWhere('key', 'Display name')['value'] ?? null)->toBe('Autopilot Profile A'); + + $block = collect($result['settings'])->firstWhere('title', 'Autopilot profile'); + expect($block)->not->toBeNull(); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Device name template')['value'] ?? null)->toBe('DEV-%SERIAL%'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Pre-provisioning (White Glove)')['value'] ?? null)->toBe('Enabled'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'OOBE: Hide EULA')['value'] ?? null)->toBe('Enabled'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'OOBE: User type')['value'] ?? null)->toBe('standard'); +}); + +it('normalizes Enrollment Status Page key fields', function () { + $snapshot = [ + '@odata.type' => '#microsoft.graph.windowsEnrollmentStatusPageConfiguration', + 'displayName' => 'ESP A', + 'priority' => 1, + 'showInstallationProgress' => true, + 'blockDeviceSetupRetryByUser' => false, + 'installProgressTimeoutInMinutes' => 60, + 'selectedMobileAppIds' => ['app-1', 'app-2'], + ]; + + $result = $this->normalizer->normalize($snapshot, 'windowsEnrollmentStatusPage', 'windows'); + + expect($result['status'])->toBe('ok'); + expect($result['warnings'])->toBe([]); + + $general = collect($result['settings'])->firstWhere('title', 'General'); + expect($general)->not->toBeNull(); + expect(collect($general['entries'] ?? [])->firstWhere('key', 'Type')['value'] ?? null)->toBe('windowsEnrollmentStatusPage'); + expect(collect($general['entries'] ?? [])->firstWhere('key', 'Display name')['value'] ?? null)->toBe('ESP A'); + + $block = collect($result['settings'])->firstWhere('title', 'Enrollment Status Page (ESP)'); + expect($block)->not->toBeNull(); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Priority')['value'] ?? null)->toBe(1); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Show installation progress')['value'] ?? null)->toBe('Enabled'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Block retry by user')['value'] ?? null)->toBe('Disabled'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Selected mobile app IDs')['value'] ?? null)->toBe(['app-1', 'app-2']); +});