feat(014): enrollment + autopilot normalized settings
This commit is contained in:
parent
fd3a71d698
commit
83962ff116
@ -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([
|
||||
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');
|
||||
|
||||
@ -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<int, array{key: string, value: mixed}>
|
||||
*/
|
||||
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<int, array{key: string, value: mixed}>}|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');
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
<div class="space-y-4">
|
||||
@ -123,6 +144,14 @@
|
||||
<span class="font-mono text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $row['value'] }}
|
||||
</span>
|
||||
@elseif($shouldRenderBadges($row['value'] ?? null))
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
@foreach(($row['value'] ?? []) as $item)
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ is_bool($item) ? ($item ? 'Enabled' : 'Disabled') : (string) $item }}
|
||||
</x-filament::badge>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<span class="text-sm text-gray-900 dark:text-white break-words">
|
||||
{{ Str::limit($stringifyValue($row['value'] ?? null), 200) }}
|
||||
@ -152,9 +181,19 @@
|
||||
{{ $entry['key'] }}
|
||||
</dt>
|
||||
<dd class="mt-1 sm:mt-0 sm:col-span-2">
|
||||
@if($shouldRenderBadges($entry['value'] ?? null))
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
@foreach(($entry['value'] ?? []) as $item)
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ is_bool($item) ? ($item ? 'Enabled' : 'Disabled') : (string) $item }}
|
||||
</x-filament::badge>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<span class="text-sm text-gray-900 dark:text-white break-words">
|
||||
{{ Str::limit($stringifyValue($entry['value'] ?? null), 200) }}
|
||||
</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => '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');
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Intune\PolicySyncService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery\MockInterface;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('policy sync does not let enrollmentRestriction claim ESP items and reclassifies existing wrong rows', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => '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');
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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']);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user