From 6a1809fbe903262099853eed1a8714c536b406ae Mon Sep 17 00:00:00 2001 From: ahmido Date: Fri, 2 Jan 2026 11:59:21 +0000 Subject: [PATCH] 014-enrollment-autopilot (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR completes Feature 014 (Enrollment & Autopilot). Adds normalization for: Autopilot deployment profiles (windowsAutopilotDeploymentProfile) Enrollment Status Page / ESP (windowsEnrollmentStatusPage) Enrollment Restrictions (enrollmentRestriction, restore remains preview-only) Improves settings readability: Autopilot OOBE settings are expanded into readable key/value entries Enrollment restriction platform restrictions are shown as explicit fields (with sensible defaults) Array/list values render as badges (avoids Blade rendering crashes on non-string values) Fixes enrollment configuration type collisions during sync: Canonical type resolution prevents enrollmentRestriction from “claiming” ESP items Safe reclassification updates existing wrong rows instead of skipping Enhances reclassification command: Can detect ESP even if a policy has no local versions (fetches snapshot from Graph) Dry-run by default; apply with --write Tests Added/updated unit + Filament feature tests for normalization and UI rendering. Preview-only enforcement for enrollment restrictions is covered. Targeted test suite and Pint are green. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/20 --- .../ReclassifyEnrollmentConfigurations.php | 162 +++++++++ app/Providers/AppServiceProvider.php | 2 + .../EnrollmentAutopilotPolicyNormalizer.php | 331 ++++++++++++++++++ app/Services/Intune/PolicySyncService.php | 121 ++++++- config/graph_contracts.php | 5 +- config/tenantpilot.php | 20 +- .../policy-settings-standard.blade.php | 87 ++++- .../checklists/requirements.md | 34 ++ specs/014-enrollment-autopilot/plan.md | 48 +++ specs/014-enrollment-autopilot/spec.md | 111 ++++++ specs/014-enrollment-autopilot/tasks.md | 33 ++ ...EnrollmentAutopilotSettingsDisplayTest.php | 151 ++++++++ .../EnrollmentRestrictionsPreviewOnlyTest.php | 111 ++++++ ...olicySettingsStandardRendersArraysTest.php | 63 ++++ ...rollmentConfigurationTypeCollisionTest.php | 73 ++++ ...ifyEnrollmentConfigurationsCommandTest.php | 106 ++++++ tests/Unit/PolicyNormalizerTest.php | 92 +++++ 17 files changed, 1514 insertions(+), 36 deletions(-) create mode 100644 app/Console/Commands/ReclassifyEnrollmentConfigurations.php create mode 100644 app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php create mode 100644 specs/014-enrollment-autopilot/checklists/requirements.md create mode 100644 specs/014-enrollment-autopilot/plan.md create mode 100644 specs/014-enrollment-autopilot/spec.md create mode 100644 specs/014-enrollment-autopilot/tasks.md create mode 100644 tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php create mode 100644 tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php create mode 100644 tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php create mode 100644 tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php create mode 100644 tests/Feature/ReclassifyEnrollmentConfigurationsCommandTest.php diff --git a/app/Console/Commands/ReclassifyEnrollmentConfigurations.php b/app/Console/Commands/ReclassifyEnrollmentConfigurations.php new file mode 100644 index 0000000..ce0c783 --- /dev/null +++ b/app/Console/Commands/ReclassifyEnrollmentConfigurations.php @@ -0,0 +1,162 @@ +resolveTenantOrNull(); + $dryRun = ! (bool) $this->option('write'); + + $query = Policy::query() + ->with(['tenant']) + ->active() + ->where('policy_type', 'enrollmentRestriction'); + + if ($tenant) { + $query->where('tenant_id', $tenant->id); + } + + $candidates = $query->get(); + + $changedVersions = 0; + $changedPolicies = 0; + $ignoredPolicies = 0; + + foreach ($candidates as $policy) { + $latestVersion = $policy->versions()->latest('version_number')->first(); + $snapshot = $latestVersion?->snapshot; + + if (! is_array($snapshot)) { + $snapshot = $this->fetchSnapshotOrNull($policy); + } + + if (! is_array($snapshot)) { + continue; + } + + if (! $this->isEspSnapshot($snapshot)) { + continue; + } + + $this->line(sprintf( + 'ESP detected: policy=%s tenant_id=%s external_id=%s', + (string) $policy->getKey(), + (string) $policy->tenant_id, + (string) $policy->external_id, + )); + + if ($dryRun) { + continue; + } + + $existingTarget = Policy::query() + ->where('tenant_id', $policy->tenant_id) + ->where('external_id', $policy->external_id) + ->where('policy_type', 'windowsEnrollmentStatusPage') + ->first(); + + 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'); + + if (! $tenantOption) { + return null; + } + + return Tenant::query() + ->forTenant($tenantOption) + ->firstOrFail(); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f6421d8..10e2a8e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -8,6 +8,7 @@ use App\Services\Intune\AppProtectionPolicyNormalizer; use App\Services\Intune\CompliancePolicyNormalizer; use App\Services\Intune\DeviceConfigurationPolicyNormalizer; +use App\Services\Intune\EnrollmentAutopilotPolicyNormalizer; use App\Services\Intune\GroupPolicyConfigurationNormalizer; use App\Services\Intune\ScriptsPolicyNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer; @@ -42,6 +43,7 @@ public function register(): void AppProtectionPolicyNormalizer::class, CompliancePolicyNormalizer::class, DeviceConfigurationPolicyNormalizer::class, + EnrollmentAutopilotPolicyNormalizer::class, GroupPolicyConfigurationNormalizer::class, ScriptsPolicyNormalizer::class, SettingsCatalogPolicyNormalizer::class, diff --git a/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php b/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php new file mode 100644 index 0000000..9177392 --- /dev/null +++ b/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php @@ -0,0 +1,331 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = is_array($snapshot) ? $snapshot : []; + + $displayName = Arr::get($snapshot, 'displayName') ?? Arr::get($snapshot, 'name'); + $description = Arr::get($snapshot, 'description'); + + $warnings = []; + + if ($policyType === 'enrollmentRestriction') { + $warnings[] = 'Restore is preview-only for Enrollment Restrictions.'; + } + + $generalEntries = [ + ['key' => 'Type', 'value' => $policyType], + ]; + + if (is_string($displayName) && $displayName !== '') { + $generalEntries[] = ['key' => 'Display name', 'value' => $displayName]; + } + + if (is_string($description) && $description !== '') { + $generalEntries[] = ['key' => 'Description', 'value' => $description]; + } + + $odataType = Arr::get($snapshot, '@odata.type'); + if (is_string($odataType) && $odataType !== '') { + $generalEntries[] = ['key' => '@odata.type', 'value' => $odataType]; + } + + $roleScopeTagIds = Arr::get($snapshot, 'roleScopeTagIds'); + if (is_array($roleScopeTagIds) && $roleScopeTagIds !== []) { + $generalEntries[] = ['key' => 'Scope tag IDs', 'value' => array_values($roleScopeTagIds)]; + } + + $settings = [ + [ + 'type' => 'keyValue', + 'title' => 'General', + 'entries' => $generalEntries, + ], + ]; + + $typeBlock = match ($policyType) { + 'windowsAutopilotDeploymentProfile' => $this->buildAutopilotBlock($snapshot), + 'windowsEnrollmentStatusPage' => $this->buildEnrollmentStatusPageBlock($snapshot), + 'enrollmentRestriction' => $this->buildEnrollmentRestrictionBlock($snapshot), + default => null, + }; + + if ($typeBlock !== null) { + $settings[] = $typeBlock; + } + + $settings = array_values(array_filter($settings)); + + return [ + 'status' => 'ok', + 'settings' => $settings, + 'warnings' => $warnings, + ]; + } + + /** + * @return array{type: string, title: string, entries: array}|null + */ + private function buildAutopilotBlock(array $snapshot): ?array + { + $entries = []; + + foreach ([ + 'deviceNameTemplate' => 'Device name template', + 'language' => 'Language', + 'locale' => 'Locale', + 'deploymentMode' => 'Deployment mode', + 'deviceType' => 'Device type', + 'enableWhiteGlove' => 'Pre-provisioning (White Glove)', + 'hybridAzureADJoinSkipConnectivityCheck' => 'Skip Hybrid AAD connectivity check', + ] as $key => $label) { + $value = Arr::get($snapshot, $key); + + if (is_string($value) && $value !== '') { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_bool($value)) { + $entries[] = ['key' => $label, 'value' => $value ? 'Enabled' : 'Disabled']; + } + } + + $oobe = Arr::get($snapshot, 'outOfBoxExperienceSettings'); + if (is_array($oobe) && $oobe !== []) { + $oobe = Arr::except($oobe, ['@odata.type']); + + foreach ($this->expandOutOfBoxExperienceEntries($oobe) as $entry) { + $entries[] = $entry; + } + } + + $assignments = Arr::get($snapshot, 'assignments'); + if (is_array($assignments) && $assignments !== []) { + $entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]']; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Autopilot profile', + 'entries' => $entries, + ]; + } + + /** + * @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 + */ + private function buildEnrollmentStatusPageBlock(array $snapshot): ?array + { + $entries = []; + + foreach ([ + 'priority' => 'Priority', + 'showInstallationProgress' => 'Show installation progress', + 'blockDeviceSetupRetryByUser' => 'Block retry by user', + 'allowDeviceResetOnInstallFailure' => 'Allow device reset on install failure', + 'installProgressTimeoutInMinutes' => 'Install progress timeout (minutes)', + 'allowLogCollectionOnInstallFailure' => 'Allow log collection on failure', + ] as $key => $label) { + $value = Arr::get($snapshot, $key); + + if (is_int($value) || is_float($value)) { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_string($value) && $value !== '') { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_bool($value)) { + $entries[] = ['key' => $label, 'value' => $value ? 'Enabled' : 'Disabled']; + } + } + + $selected = Arr::get($snapshot, 'selectedMobileAppIds'); + if (is_array($selected) && $selected !== []) { + $entries[] = ['key' => 'Selected mobile app IDs', 'value' => array_values($selected)]; + } + + $assigned = Arr::get($snapshot, 'assignments'); + if (is_array($assigned) && $assigned !== []) { + $entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]']; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Enrollment Status Page (ESP)', + 'entries' => $entries, + ]; + } + + /** + * @return array{type: string, title: string, entries: array}|null + */ + private function buildEnrollmentRestrictionBlock(array $snapshot): ?array + { + $entries = []; + + foreach ([ + 'priority' => 'Priority', + 'version' => 'Version', + 'deviceEnrollmentConfigurationType' => 'Configuration type', + ] as $key => $label) { + $value = Arr::get($snapshot, $key); + + if (is_int($value) || is_float($value)) { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_string($value) && $value !== '') { + $entries[] = ['key' => $label, 'value' => $value]; + } + } + + $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'); + if (is_array($assigned) && $assigned !== []) { + $entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]']; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Enrollment restrictions', + 'entries' => $entries, + ]; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $normalized = $this->normalize($snapshot ?? [], $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } +} 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/config/tenantpilot.php b/config/tenantpilot.php index 17efa20..7ec3820 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -124,16 +124,6 @@ 'restore' => 'enabled', 'risk' => 'medium', ], - [ - 'type' => 'enrollmentRestriction', - 'label' => 'Enrollment Restrictions', - 'category' => 'Enrollment', - 'platform' => 'all', - 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', - 'backup' => 'full', - 'restore' => 'preview-only', - 'risk' => 'high', - ], [ 'type' => 'windowsAutopilotDeploymentProfile', 'label' => 'Windows Autopilot Profiles', @@ -155,6 +145,16 @@ 'restore' => 'enabled', 'risk' => 'medium', ], + [ + 'type' => 'enrollmentRestriction', + 'label' => 'Enrollment Restrictions', + 'category' => 'Enrollment', + 'platform' => 'all', + 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', + 'backup' => 'full', + 'restore' => 'preview-only', + 'risk' => 'high', + ], [ 'type' => 'endpointSecurityIntent', 'label' => 'Endpoint Security Intents', 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 aabb323..f644e9f 100644 --- a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php +++ b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php @@ -7,7 +7,58 @@ $warnings = $state['warnings'] ?? []; $settings = $state['settings'] ?? []; $settingsTable = $state['settings_table'] ?? null; + $policyType = $state['policy_type'] ?? null; + + $stringifyValue = function (mixed $value): string { + if (is_null($value)) { + return 'N/A'; + } + + if (is_bool($value)) { + return $value ? 'Enabled' : 'Disabled'; + } + + if (is_scalar($value)) { + return (string) $value; + } + + if (is_array($value)) { + $encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + return is_string($encoded) ? $encoded : 'N/A'; + } + + if (is_object($value)) { + if (method_exists($value, '__toString')) { + return (string) $value; + } + + $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
@@ -99,16 +150,17 @@ {{ $row['value'] }} + @elseif($shouldRenderBadges($row['value'] ?? null)) +
+ @foreach(($row['value'] ?? []) as $item) + + {{ is_bool($item) ? ($item ? 'Enabled' : 'Disabled') : (string) $item }} + + @endforeach +
@else - @php - $value = $row['value'] ?? 'N/A'; - - if (is_array($value) || is_object($value)) { - $value = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - } - @endphp - {{ Str::limit((string) $value, 200) }} + {{ Str::limit($stringifyValue($row['value'] ?? null), 200) }} @endif @@ -136,18 +188,15 @@
@php - $value = $entry['value'] ?? 'N/A'; + $rawValue = $entry['value'] ?? null; $isScriptContent = in_array($entry['key'] ?? null, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true) && (bool) config('tenantpilot.display.show_script_content', false); - - if (is_array($value) || is_object($value)) { - $value = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - } @endphp + @if($isScriptContent) @php - $code = (string) $value; + $code = is_string($rawValue) ? $rawValue : $stringifyValue($rawValue); $firstLine = strtok($code, "\n") ?: ''; $grammar = 'powershell'; @@ -219,9 +268,17 @@ @endif
+ @elseif($shouldRenderBadges($rawValue)) +
+ @foreach(($rawValue ?? []) as $item) + + {{ is_bool($item) ? ($item ? 'Enabled' : 'Disabled') : (string) $item }} + + @endforeach +
@else - {{ Str::limit((string) $value, 200) }} + {{ Str::limit($stringifyValue($rawValue), 200) }} @endif diff --git a/specs/014-enrollment-autopilot/checklists/requirements.md b/specs/014-enrollment-autopilot/checklists/requirements.md new file mode 100644 index 0000000..d73ba6c --- /dev/null +++ b/specs/014-enrollment-autopilot/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Enrollment & Autopilot + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-01-01 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Assumptions: Restore behavior for enrollment restrictions remains preview-only until a separate product decision explicitly enables it. diff --git a/specs/014-enrollment-autopilot/plan.md b/specs/014-enrollment-autopilot/plan.md new file mode 100644 index 0000000..a283ad3 --- /dev/null +++ b/specs/014-enrollment-autopilot/plan.md @@ -0,0 +1,48 @@ +# Plan: Enrollment & Autopilot (014) + +**Branch**: `014-enrollment-autopilot` +**Date**: 2026-01-01 +**Input**: [spec.md](./spec.md) + +## Goal +Provide end-to-end support for enrollment & Autopilot configuration items with readable normalized settings and safe restore behavior. + +## Scope + +### In scope +- Policy types: + - `windowsAutopilotDeploymentProfile` (restore enabled) + - `windowsEnrollmentStatusPage` (restore enabled) + - `enrollmentRestriction` (restore preview-only) +- Readable “Normalized settings” for the above types. +- Restore behavior: + - Autopilot/ESP: apply via existing restore mechanisms (create-if-missing allowed) + - Enrollment restrictions: must be skipped on execution by default (preview-only) +- Tests for normalization + UI rendering + preview-only enforcement. + +### Out of scope +- New restore wizard flows/pages. +- Enabling execution for enrollment restrictions (requires product decision). +- New external services. + +## Approach +1. Verify `config/graph_contracts.php` and `config/tenantpilot.php` entries for the three policy types. +2. Implement a new policy type normalizer to provide stable, enrollment-relevant blocks for: + - Autopilot deployment profiles + - Enrollment Status Page + - Enrollment restrictions +3. Register the normalizer with the `policy-type-normalizers` tag. +4. Add tests: + - Unit tests for normalized output stability/shape. + - Filament feature tests verifying “Normalized settings” renders for each type. + - Feature test verifying `enrollmentRestriction` restore is preview-only and skipped on execution. +5. Run targeted tests and Pint. + +## Risks & Mitigations +- Payload shape variance across tenants: normalizer must handle missing keys safely. +- Enrollment restrictions are high impact: execution must remain disabled by default (preview-only). + +## Success Criteria +- Normalized settings are stable and readable for all in-scope types. +- Restore execution skips preview-only types and reports clear result reasons. +- Tests cover normalization and preview-only enforcement. diff --git a/specs/014-enrollment-autopilot/spec.md b/specs/014-enrollment-autopilot/spec.md new file mode 100644 index 0000000..edef695 --- /dev/null +++ b/specs/014-enrollment-autopilot/spec.md @@ -0,0 +1,111 @@ +# Feature Specification: Enrollment & Autopilot + +**Feature Branch**: `014-enrollment-autopilot` +**Created**: 2026-01-01 +**Status**: Draft +**Input**: User description: "Improve enrollment and Autopilot configuration safety by adding readable normalized settings, reliable snapshot capture, and safe restore behavior for enrollment restrictions, enrollment status page, and Autopilot deployment profiles." + +## User Scenarios & Testing *(mandatory)* + + + +### User Story 1 - Restore Autopilot/ESP safely (Priority: P1) + +As an admin, I want to restore Autopilot deployment profiles and the Enrollment Status Page configuration from saved snapshots so I can recover enrollment readiness after changes. + +**Why this priority**: Enrollment misconfiguration blocks device onboarding; fast recovery is critical. + +**Independent Test**: Can be tested by restoring one Autopilot profile and one Enrollment Status Page item from snapshots into a target tenant and verifying they match the snapshot. + +**Acceptance Scenarios**: + +1. **Given** a saved Autopilot deployment profile snapshot and a target tenant where the profile is missing, **When** I restore it, **Then** a new profile is created and restore reports success. +2. **Given** a saved Enrollment Status Page snapshot and a target tenant where the item exists with differences, **When** I restore it, **Then** the configuration is updated to match the snapshot and restore reports success. + +--- + +### User Story 2 - Restore behavior is explicit for high-risk enrollment restrictions (Priority: P2) + +As an admin, I want high-risk enrollment restrictions to be handled explicitly (preview-only unless intentionally enabled) so I do not accidentally break enrollment flows. + +**Why this priority**: Enrollment restrictions can lock out device onboarding; accidental changes are high impact. + +**Independent Test**: Can be tested by attempting restore of an enrollment restriction item and verifying the system does not apply changes when it is configured as preview-only. + +**Acceptance Scenarios**: + +1. **Given** an enrollment restriction snapshot and the feature is allowed for preview-only, **When** I run restore execution, **Then** the system skips applying changes and records a result indicating preview-only behavior. + +--- + +### User Story 3 - Readable normalized settings (Priority: P3) + +As an admin, I want to view readable normalized settings for Autopilot and Enrollment configurations so I can understand what will happen during device onboarding. + +**Why this priority**: Enrollment troubleshooting is faster when key settings are visible and consistent. + +**Independent Test**: Can be tested by opening a version details page and confirming a stable normalized settings view is present and readable. + +**Acceptance Scenarios**: + +1. **Given** a saved Autopilot/ESP snapshot, **When** I view the policy version, **Then** I see a normalized settings view that highlights key enrollment-relevant fields. + +--- + +[Add more user stories as needed, each with an assigned priority] + +### Edge Cases + +- Autopilot or ESP configuration in the target tenant is missing: system must create or clearly fail with an actionable reason. +- Restoring Enrollment Status Page items must not silently drop settings; failures must be explicit. +- Enrollment restrictions remain preview-only unless explicitly enabled by product decision; execution must not apply them by default. +- Assignments (if present for these types) that cannot be mapped must be reported as manual-required. + +## Requirements *(mandatory)* + + + +### Functional Requirements + +- **FR-001**: System MUST support listing and viewing enrollment and Autopilot configuration items for the supported types. +- **FR-002**: System MUST capture snapshots for these configuration items that are sufficient for later restore. +- **FR-003**: System MUST support restore for Autopilot deployment profiles and Enrollment Status Page configuration. +- **FR-004**: System MUST treat enrollment restrictions as high risk and default them to preview-only behavior unless explicitly enabled. +- **FR-005**: System MUST present a readable normalized settings view for these configuration items and their versions. +- **FR-006**: System MUST prevent restore execution if the snapshot type does not match the target item type. +- **FR-007**: System MUST record audit entries for restore preview and restore execution attempts. + +### Key Entities *(include if feature involves data)* + +- **Autopilot Deployment Profile**: A configuration object that defines device provisioning behavior during Autopilot. +- **Enrollment Status Page Configuration**: A configuration object that defines the onboarding status experience during enrollment. +- **Enrollment Restriction**: A high-risk configuration object that can block or constrain enrollment. +- **Snapshot**: An immutable capture of a configuration object at a point in time. + +## Success Criteria *(mandatory)* + + + +### Measurable Outcomes + +- **SC-001**: An admin can complete a restore preview for a single Autopilot/ESP item in under 1 minute. +- **SC-002**: In a test tenant, restoring Autopilot deployment profiles and Enrollment Status Page results in configurations matching the snapshot for 100% of supported items. +- **SC-003**: Enrollment restrictions remain non-executable by default (preview-only) with clear status reporting in 100% of attempts. +- **SC-004**: Normalized settings views for these items are stable and readable (same snapshot yields identical normalized output). diff --git a/specs/014-enrollment-autopilot/tasks.md b/specs/014-enrollment-autopilot/tasks.md new file mode 100644 index 0000000..0d507a1 --- /dev/null +++ b/specs/014-enrollment-autopilot/tasks.md @@ -0,0 +1,33 @@ +# Tasks: Enrollment & Autopilot (014) + +**Branch**: `014-enrollment-autopilot` | **Date**: 2026-01-01 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Contracts Review +- [x] T001 Verify `config/graph_contracts.php` entries for: + - `windowsAutopilotDeploymentProfile` + - `windowsEnrollmentStatusPage` + - `enrollmentRestriction` + (resource, type_family, create/update methods, assignment paths/payload keys) +- [x] T002 Verify `config/tenantpilot.php` entries and restore modes: + - Autopilot/ESP = `enabled` + - Enrollment restrictions = `preview-only` + +## Phase 2: UI Normalization +- [x] T003 Add an `EnrollmentAutopilotPolicyNormalizer` (or equivalent) that produces readable normalized settings for the three policy types. +- [x] T004 Register the normalizer in the app container/provider (tag `policy-type-normalizers`). + +## 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 +- [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/EnrollmentRestrictionsPreviewOnlyTest.php b/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php new file mode 100644 index 0000000..051fcb0 --- /dev/null +++ b/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php @@ -0,0 +1,111 @@ + []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->applyCalls++; + + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }; + + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-enrollment-restriction', + 'name' => 'Tenant Enrollment Restriction', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'enrollment-restriction-1', + 'policy_type' => 'enrollmentRestriction', + 'display_name' => 'Enrollment Restriction', + 'platform' => 'all', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Enrollment Restriction Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $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' => [ + '@odata.type' => '#microsoft.graph.deviceEnrollmentConfiguration', + 'id' => $policy->external_id, + 'displayName' => $policy->display_name, + ], + ]); + + $service = app(RestoreService::class); + $preview = $service->preview($tenant, $backupSet, [$backupItem->id]); + + $previewItem = collect($preview)->first(fn (array $item) => ($item['policy_type'] ?? null) === 'enrollmentRestriction'); + + expect($previewItem)->not->toBeNull() + ->and($previewItem['restore_mode'] ?? null)->toBe('preview-only'); + + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: 'tester@example.com', + actorName: 'Tester', + ); + + expect($run->results)->toHaveCount(1); + expect($run->results[0]['status'])->toBe('skipped'); + expect($run->results[0]['reason'])->toBe('preview_only'); + + expect($client->applyCalls)->toBe(0); +}); diff --git a/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php b/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php new file mode 100644 index 0000000..566c323 --- /dev/null +++ b/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php @@ -0,0 +1,63 @@ + 'tenant-arrays', + 'name' => 'Tenant Arrays', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-arrays-1', + 'policy_type' => 'windowsAutopilotDeploymentProfile', + 'display_name' => 'Autopilot Policy With Arrays', + 'platform' => 'windows', + ]); + + PolicyVersion::create([ + 'tenant_id' => $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.windowsAutopilotDeploymentProfile', + 'displayName' => 'Autopilot Policy With Arrays', + 'roleScopeTagIds' => ['0', '1'], + 'outOfBoxExperienceSettings' => [ + 'hideEULA' => true, + 'userType' => 'standard', + ], + ], + ]); + + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->get(PolicyResource::getUrl('view', ['record' => $policy]).'?tab=settings'); + + $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 new file mode 100644 index 0000000..5186cdb --- /dev/null +++ b/tests/Feature/ReclassifyEnrollmentConfigurationsCommandTest.php @@ -0,0 +1,106 @@ + 'tenant-reclassify', + 'name' => 'Tenant Reclassify', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'esp-1', + 'policy_type' => 'enrollmentRestriction', + 'display_name' => 'ESP Misclassified', + 'platform' => 'all', + ]); + + $version = PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => 'enrollmentRestriction', + 'platform' => 'all', + 'created_by' => 'tester@example.com', + 'captured_at' => CarbonImmutable::now(), + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration', + 'deviceEnrollmentConfigurationType' => 'windows10EnrollmentCompletionPageConfiguration', + 'displayName' => 'ESP Misclassified', + ], + ]); + + $this->artisan('intune:reclassify-enrollment-configurations', ['--tenant' => $tenant->tenant_id]) + ->assertSuccessful(); + + $version->refresh(); + $policy->refresh(); + + expect($version->policy_type)->toBe('enrollmentRestriction'); + expect($policy->policy_type)->toBe('enrollmentRestriction'); + + $this->artisan('intune:reclassify-enrollment-configurations', ['--tenant' => $tenant->tenant_id, '--write' => true]) + ->assertSuccessful(); + + $version->refresh(); + $policy->refresh(); + + 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']); +});