feat/027-enrollment-config-subtypes (#31)

expose enrollment config subtypes as their own policy types (limit/platform restrictions/notifications) with preview-only restore risk and proper Graph contracts
classify enrollment configs by their @odata.type + deviceEnrollmentConfigurationType so sync only keeps ESP in windowsEnrollmentStatusPage and the rest stay in their own types, including new restore-normalizer UI blocks + warnings
hydrate enrollment notifications: snapshot fetch now downloads each notification template + localized messages, normalized view surfaces template names/subjects/messages, and restore previews keep preview-only behavior
tenant UI tweaks: Tenant list and detail actions moved into an action group; “Open in Entra” re-added in index, and detail now has “Deactivate” + tests covering the new menu layout and actions
tests added/updated for sync, snapshots, restores, normalized settings, tenant UI, plus Pint/test suite run

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #31
This commit is contained in:
ahmido 2026-01-04 13:25:15 +00:00
parent 83f1814254
commit 817ad208da
17 changed files with 1230 additions and 67 deletions

View File

@ -157,6 +157,12 @@ public static function table(Table $table): Table
->url(fn (Tenant $record) => static::adminConsentUrl($record)) ->url(fn (Tenant $record) => static::adminConsentUrl($record))
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null) ->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
->openUrlInNewTab(), ->openUrlInNewTab(),
Actions\Action::make('open_in_entra')
->label('Open in Entra')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (Tenant $record) => static::entraUrl($record))
->visible(fn (Tenant $record) => static::entraUrl($record) !== null)
->openUrlInNewTab(),
Actions\Action::make('verify') Actions\Action::make('verify')
->label('Verify configuration') ->label('Verify configuration')
->icon('heroicon-o-check-badge') ->icon('heroicon-o-check-badge')

View File

@ -9,6 +9,7 @@
use App\Services\Intune\TenantConfigService; use App\Services\Intune\TenantConfigService;
use App\Services\Intune\TenantPermissionService; use App\Services\Intune\TenantPermissionService;
use Filament\Actions; use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
class ViewTenant extends ViewRecord class ViewTenant extends ViewRecord
@ -18,6 +19,7 @@ class ViewTenant extends ViewRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\ActionGroup::make([
Actions\EditAction::make(), Actions\EditAction::make(),
Actions\Action::make('admin_consent') Actions\Action::make('admin_consent')
->label('Admin consent') ->label('Admin consent')
@ -46,6 +48,34 @@ protected function getHeaderActions(): array
TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger); TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
}), }),
TenantResource::rbacAction(), TenantResource::rbacAction(),
Actions\Action::make('archive')
->label('Deactivate')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->visible(fn (Tenant $record) => ! $record->trashed())
->action(function (Tenant $record, AuditLogger $auditLogger) {
$record->delete();
$auditLogger->log(
tenant: $record,
action: 'tenant.archived',
resourceType: 'tenant',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
);
Notification::make()
->title('Tenant deactivated')
->body('The tenant has been archived and hidden from lists.')
->success()
->send();
}),
])
->label('Actions')
->icon('heroicon-o-ellipsis-vertical')
->color('gray'),
]; ];
} }
} }

View File

@ -15,6 +15,9 @@ public function supports(string $policyType): bool
'windowsAutopilotDeploymentProfile', 'windowsAutopilotDeploymentProfile',
'windowsEnrollmentStatusPage', 'windowsEnrollmentStatusPage',
'enrollmentRestriction', 'enrollmentRestriction',
'deviceEnrollmentLimitConfiguration',
'deviceEnrollmentPlatformRestrictionsConfiguration',
'deviceEnrollmentNotificationConfiguration',
], true); ], true);
} }
@ -34,6 +37,18 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor
$warnings[] = 'Restore is preview-only for Enrollment Restrictions.'; $warnings[] = 'Restore is preview-only for Enrollment Restrictions.';
} }
if ($policyType === 'deviceEnrollmentLimitConfiguration') {
$warnings[] = 'Restore is preview-only for Enrollment Limits.';
}
if ($policyType === 'deviceEnrollmentPlatformRestrictionsConfiguration') {
$warnings[] = 'Restore is preview-only for Platform Restrictions.';
}
if ($policyType === 'deviceEnrollmentNotificationConfiguration') {
$warnings[] = 'Restore is preview-only for Enrollment Notifications.';
}
$generalEntries = [ $generalEntries = [
['key' => 'Type', 'value' => $policyType], ['key' => 'Type', 'value' => $policyType],
]; ];
@ -68,6 +83,9 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor
'windowsAutopilotDeploymentProfile' => $this->buildAutopilotBlock($snapshot), 'windowsAutopilotDeploymentProfile' => $this->buildAutopilotBlock($snapshot),
'windowsEnrollmentStatusPage' => $this->buildEnrollmentStatusPageBlock($snapshot), 'windowsEnrollmentStatusPage' => $this->buildEnrollmentStatusPageBlock($snapshot),
'enrollmentRestriction' => $this->buildEnrollmentRestrictionBlock($snapshot), 'enrollmentRestriction' => $this->buildEnrollmentRestrictionBlock($snapshot),
'deviceEnrollmentLimitConfiguration' => $this->buildEnrollmentLimitBlock($snapshot),
'deviceEnrollmentPlatformRestrictionsConfiguration' => $this->buildEnrollmentPlatformRestrictionsBlock($snapshot),
'deviceEnrollmentNotificationConfiguration' => $this->buildEnrollmentNotificationBlock($snapshot),
default => null, default => null,
}; };
@ -319,6 +337,266 @@ private function buildEnrollmentRestrictionBlock(array $snapshot): ?array
]; ];
} }
/**
* @return array{type: string, title: string, entries: array<int, array{key: string, value: mixed}>}|null
*/
private function buildEnrollmentLimitBlock(array $snapshot): ?array
{
$entries = [];
foreach ([
'priority' => 'Priority',
'version' => 'Version',
'deviceEnrollmentConfigurationType' => 'Configuration type',
'limit' => 'Enrollment limit',
'limitType' => 'Limit 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];
} elseif (is_bool($value)) {
$entries[] = ['key' => $label, 'value' => $value ? 'Enabled' : 'Disabled'];
}
}
$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 limits',
'entries' => $entries,
];
}
/**
* @return array{type: string, title: string, entries: array<int, array{key: string, value: mixed}>}|null
*/
private function buildEnrollmentPlatformRestrictionsBlock(array $snapshot): ?array
{
$entries = [];
foreach ([
'priority' => 'Priority',
'version' => 'Version',
'platformType' => 'Platform type',
'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];
}
}
$platformPayload = Arr::get($snapshot, 'platformRestrictions') ?? Arr::get($snapshot, 'platformRestriction');
if (is_array($platformPayload) && $platformPayload !== []) {
$prefix = (string) (Arr::get($snapshot, 'platformType') ?: 'Platform');
$this->appendPlatformRestrictionEntries($entries, $prefix, $platformPayload);
}
$typedRestrictions = [
'androidForWorkRestriction' => 'Android work profile',
'androidRestriction' => 'Android',
'iosRestriction' => 'iOS/iPadOS',
'macRestriction' => 'macOS',
'windowsRestriction' => 'Windows',
];
foreach ($typedRestrictions as $key => $prefix) {
$restriction = Arr::get($snapshot, $key);
if (! is_array($restriction) || $restriction === []) {
continue;
}
$this->appendPlatformRestrictionEntries($entries, $prefix, $restriction);
}
$assigned = Arr::get($snapshot, 'assignments');
if (is_array($assigned) && $assigned !== []) {
$entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]'];
}
if ($entries === []) {
return null;
}
return [
'type' => 'keyValue',
'title' => 'Platform restrictions (enrollment)',
'entries' => $entries,
];
}
/**
* @param array<int, array{key: string, value: mixed}> $entries
*/
private function appendPlatformRestrictionEntries(array &$entries, string $prefix, array $payload): void
{
$payload = Arr::except($payload, ['@odata.type']);
$platformBlocked = Arr::get($payload, 'platformBlocked');
if (is_bool($platformBlocked)) {
$entries[] = ['key' => "{$prefix}: Platform blocked", 'value' => $platformBlocked ? 'Enabled' : 'Disabled'];
}
$personalBlocked = Arr::get($payload, 'personalDeviceEnrollmentBlocked');
if (is_bool($personalBlocked)) {
$entries[] = ['key' => "{$prefix}: Personal device enrollment blocked", 'value' => $personalBlocked ? 'Enabled' : 'Disabled'];
}
$osMin = Arr::get($payload, 'osMinimumVersion');
$entries[] = [
'key' => "{$prefix}: OS minimum version",
'value' => (is_string($osMin) && $osMin !== '') ? $osMin : 'None',
];
$osMax = Arr::get($payload, 'osMaximumVersion');
$entries[] = [
'key' => "{$prefix}: OS maximum version",
'value' => (is_string($osMax) && $osMax !== '') ? $osMax : 'None',
];
$blockedManufacturers = Arr::get($payload, 'blockedManufacturers');
$entries[] = [
'key' => "{$prefix}: Blocked manufacturers",
'value' => (is_array($blockedManufacturers) && $blockedManufacturers !== [])
? array_values($blockedManufacturers)
: ['None'],
];
$blockedSkus = Arr::get($payload, 'blockedSkus');
$entries[] = [
'key' => "{$prefix}: Blocked SKUs",
'value' => (is_array($blockedSkus) && $blockedSkus !== [])
? array_values($blockedSkus)
: ['None'],
];
}
/**
* @return array{type: string, title: string, entries: array<int, array{key: string, value: mixed}>}|null
*/
private function buildEnrollmentNotificationBlock(array $snapshot): ?array
{
$entries = [];
foreach ([
'priority' => 'Priority',
'version' => 'Version',
'platformType' => 'Platform type',
'deviceEnrollmentConfigurationType' => 'Configuration type',
'brandingOptions' => 'Branding options',
'templateType' => 'Template type',
'defaultLocale' => 'Default locale',
'notificationMessageTemplateId' => 'Notification message template ID',
] 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'];
}
}
$notificationTemplates = Arr::get($snapshot, 'notificationTemplates');
if (is_array($notificationTemplates) && $notificationTemplates !== []) {
$entries[] = ['key' => 'Notification templates', 'value' => array_values($notificationTemplates)];
}
$templateSnapshots = Arr::get($snapshot, 'notificationTemplateSnapshots');
if (is_array($templateSnapshots) && $templateSnapshots !== []) {
foreach ($templateSnapshots as $templateSnapshot) {
if (! is_array($templateSnapshot)) {
continue;
}
$channel = Arr::get($templateSnapshot, 'channel');
$channelLabel = is_string($channel) && $channel !== '' ? $channel : 'Template';
$templateId = Arr::get($templateSnapshot, 'template_id');
if (is_string($templateId) && $templateId !== '') {
$entries[] = ['key' => "{$channelLabel} template ID", 'value' => $templateId];
}
$template = Arr::get($templateSnapshot, 'template');
if (is_array($template) && $template !== []) {
$displayName = Arr::get($template, 'displayName');
if (is_string($displayName) && $displayName !== '') {
$entries[] = ['key' => "{$channelLabel} template name", 'value' => $displayName];
}
$brandingOptions = Arr::get($template, 'brandingOptions');
if (is_string($brandingOptions) && $brandingOptions !== '') {
$entries[] = ['key' => "{$channelLabel} branding options", 'value' => $brandingOptions];
}
$defaultLocale = Arr::get($template, 'defaultLocale');
if (is_string($defaultLocale) && $defaultLocale !== '') {
$entries[] = ['key' => "{$channelLabel} default locale", 'value' => $defaultLocale];
}
}
$localizedMessages = Arr::get($templateSnapshot, 'localized_notification_messages');
if (is_array($localizedMessages) && $localizedMessages !== []) {
foreach ($localizedMessages as $localizedMessage) {
if (! is_array($localizedMessage)) {
continue;
}
$locale = Arr::get($localizedMessage, 'locale');
$localeLabel = is_string($locale) && $locale !== '' ? $locale : 'locale';
$subject = Arr::get($localizedMessage, 'subject');
if (is_string($subject) && $subject !== '') {
$entries[] = ['key' => "{$channelLabel} ({$localeLabel}) Subject", 'value' => $subject];
}
$messageTemplate = Arr::get($localizedMessage, 'messageTemplate');
if (is_string($messageTemplate) && $messageTemplate !== '') {
$entries[] = ['key' => "{$channelLabel} ({$localeLabel}) Message", 'value' => $messageTemplate];
}
$isDefault = Arr::get($localizedMessage, 'isDefault');
if (is_bool($isDefault)) {
$entries[] = ['key' => "{$channelLabel} ({$localeLabel}) Default", 'value' => $isDefault ? 'Enabled' : 'Disabled'];
}
}
}
}
}
$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 notifications',
'entries' => $entries,
];
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */

View File

@ -124,6 +124,15 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
); );
} }
if ($policy->policy_type === 'deviceEnrollmentNotificationConfiguration') {
[$payload, $metadata] = $this->hydrateEnrollmentNotificationTemplates(
tenantIdentifier: $tenantIdentifier,
tenant: $tenant,
payload: is_array($payload) ? $payload : [],
metadata: $metadata
);
}
if ($response->failed()) { if ($response->failed()) {
$reason = $this->formatGraphFailureReason($response); $reason = $this->formatGraphFailureReason($response);
@ -607,6 +616,126 @@ private function hydrateComplianceActions(string $tenantIdentifier, Tenant $tena
return [$payload, $metadata]; return [$payload, $metadata];
} }
/**
* Hydrate enrollment notifications with message template details.
*
* @return array{0:array,1:array}
*/
private function hydrateEnrollmentNotificationTemplates(string $tenantIdentifier, Tenant $tenant, array $payload, array $metadata): array
{
$existing = $payload['notificationTemplateSnapshots'] ?? null;
if (is_array($existing) && $existing !== []) {
$metadata['enrollment_notification_templates_hydration'] = 'embedded';
return [$payload, $metadata];
}
$templateRefs = $payload['notificationTemplates'] ?? null;
if (! is_array($templateRefs) || $templateRefs === []) {
$metadata['enrollment_notification_templates_hydration'] = 'none';
return [$payload, $metadata];
}
$options = [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
];
$snapshots = [];
$failures = 0;
foreach ($templateRefs as $templateRef) {
if (! is_string($templateRef) || $templateRef === '') {
continue;
}
[$channel, $templateId] = $this->parseEnrollmentNotificationTemplateRef($templateRef);
if ($templateId === null) {
$failures++;
continue;
}
$templatePath = sprintf('deviceManagement/notificationMessageTemplates/%s', urlencode($templateId));
$templateResponse = $this->graphClient->request('GET', $templatePath, $options);
if ($templateResponse->failed() || ! is_array($templateResponse->data)) {
$failures++;
continue;
}
$template = Arr::except($templateResponse->data, ['@odata.context']);
$messagesPath = sprintf(
'deviceManagement/notificationMessageTemplates/%s/localizedNotificationMessages',
urlencode($templateId)
);
$messagesResponse = $this->graphClient->request('GET', $messagesPath, $options);
$messages = [];
if ($messagesResponse->failed()) {
$failures++;
} else {
$pageItems = $messagesResponse->data['value'] ?? [];
if (is_array($pageItems)) {
foreach ($pageItems as $message) {
if (is_array($message)) {
$messages[] = Arr::except($message, ['@odata.context']);
}
}
}
}
$snapshots[] = [
'channel' => $channel,
'template_id' => $templateId,
'template' => $template,
'localized_notification_messages' => $messages,
];
}
if ($snapshots === []) {
$metadata['enrollment_notification_templates_hydration'] = 'failed';
return [$payload, $metadata];
}
$payload['notificationTemplateSnapshots'] = $snapshots;
$metadata['enrollment_notification_templates_hydration'] = $failures > 0 ? 'partial' : 'complete';
return [$payload, $metadata];
}
/**
* @return array{0:?string,1:?string}
*/
private function parseEnrollmentNotificationTemplateRef(string $templateRef): array
{
if (! str_contains($templateRef, '_')) {
return [null, $templateRef];
}
[$channel, $templateId] = explode('_', $templateRef, 2);
$channel = trim($channel);
$templateId = trim($templateId);
if ($templateId === '') {
return [$channel !== '' ? $channel : null, null];
}
return [$channel !== '' ? $channel : null, $templateId];
}
/** /**
* Extract all settingDefinitionId from settings array, including nested children. * Extract all settingDefinitionId from settings array, including nested children.
*/ */

View File

@ -167,7 +167,15 @@ private function resolveCanonicalPolicyType(string $policyType, array $policyDat
return $this->resolveConfigurationPolicyType($policyData); return $this->resolveConfigurationPolicyType($policyData);
} }
if (! in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) { $enrollmentConfigurationTypes = [
'enrollmentRestriction',
'windowsEnrollmentStatusPage',
'deviceEnrollmentLimitConfiguration',
'deviceEnrollmentPlatformRestrictionsConfiguration',
'deviceEnrollmentNotificationConfiguration',
];
if (! in_array($policyType, $enrollmentConfigurationTypes, true)) {
return $policyType; return $policyType;
} }
@ -175,6 +183,18 @@ private function resolveCanonicalPolicyType(string $policyType, array $policyDat
return 'windowsEnrollmentStatusPage'; return 'windowsEnrollmentStatusPage';
} }
if ($this->isEnrollmentNotificationItem($policyData)) {
return 'deviceEnrollmentNotificationConfiguration';
}
if ($this->isEnrollmentLimitItem($policyData)) {
return 'deviceEnrollmentLimitConfiguration';
}
if ($this->isEnrollmentPlatformRestrictionsItem($policyData)) {
return 'deviceEnrollmentPlatformRestrictionsConfiguration';
}
return 'enrollmentRestriction'; return 'enrollmentRestriction';
} }
@ -255,13 +275,77 @@ private function isEnrollmentStatusPageItem(array $policyData): bool
|| (is_string($configurationType) && $configurationType === 'windows10EnrollmentCompletionPageConfiguration'); || (is_string($configurationType) && $configurationType === 'windows10EnrollmentCompletionPageConfiguration');
} }
private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void private function isEnrollmentLimitItem(array $policyData): bool
{ {
if (! in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) { $odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null;
return; $configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null;
return (is_string($odataType) && strcasecmp($odataType, '#microsoft.graph.deviceEnrollmentLimitConfiguration') === 0)
|| (is_string($configurationType) && strcasecmp($configurationType, 'deviceEnrollmentLimitConfiguration') === 0);
} }
$enrollmentTypes = ['enrollmentRestriction', 'windowsEnrollmentStatusPage']; private function isEnrollmentPlatformRestrictionsItem(array $policyData): bool
{
$odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null;
$configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null;
if (is_string($odataType) && $odataType !== '') {
$odataTypeKey = strtolower($odataType);
if (in_array($odataTypeKey, [
'#microsoft.graph.deviceenrollmentplatformrestrictionconfiguration',
'#microsoft.graph.deviceenrollmentplatformrestrictionsconfiguration',
], true)) {
return true;
}
}
if (is_string($configurationType) && $configurationType !== '') {
$configurationTypeKey = strtolower($configurationType);
if (in_array($configurationTypeKey, [
'deviceenrollmentplatformrestrictionconfiguration',
'deviceenrollmentplatformrestrictionsconfiguration',
], true)) {
return true;
}
}
return false;
}
private function isEnrollmentNotificationItem(array $policyData): bool
{
$odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null;
$configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null;
if (is_string($odataType) && strcasecmp($odataType, '#microsoft.graph.deviceEnrollmentNotificationConfiguration') === 0) {
return true;
}
if (! is_string($configurationType) || $configurationType === '') {
return false;
}
return in_array(strtolower($configurationType), [
'enrollmentnotificationsconfiguration',
'deviceenrollmentnotificationconfiguration',
], true);
}
private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void
{
$enrollmentTypes = [
'enrollmentRestriction',
'windowsEnrollmentStatusPage',
'deviceEnrollmentLimitConfiguration',
'deviceEnrollmentPlatformRestrictionsConfiguration',
'deviceEnrollmentNotificationConfiguration',
];
if (! in_array($policyType, $enrollmentTypes, true)) {
return;
}
$existingCorrect = Policy::query() $existingCorrect = Policy::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)

View File

@ -73,6 +73,15 @@ protected static function odataTypeMap(): array
'enrollmentRestriction' => [ 'enrollmentRestriction' => [
'all' => '#microsoft.graph.deviceEnrollmentConfiguration', 'all' => '#microsoft.graph.deviceEnrollmentConfiguration',
], ],
'deviceEnrollmentLimitConfiguration' => [
'all' => '#microsoft.graph.deviceEnrollmentLimitConfiguration',
],
'deviceEnrollmentPlatformRestrictionsConfiguration' => [
'all' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration',
],
'deviceEnrollmentNotificationConfiguration' => [
'all' => '#microsoft.graph.deviceEnrollmentNotificationConfiguration',
],
'windowsAutopilotDeploymentProfile' => [ 'windowsAutopilotDeploymentProfile' => [
'windows' => '#microsoft.graph.windowsAutopilotDeploymentProfile', 'windows' => '#microsoft.graph.windowsAutopilotDeploymentProfile',
], ],

View File

@ -518,14 +518,29 @@
'assignments_delete_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}', 'assignments_delete_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE', 'assignments_delete_method' => 'DELETE',
], ],
'enrollmentRestriction' => [ 'deviceEnrollmentLimitConfiguration' => [
'resource' => 'deviceManagement/deviceEnrollmentConfigurations',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.deviceEnrollmentLimitConfiguration',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_payload_key' => 'enrollmentConfigurationAssignments',
],
'deviceEnrollmentPlatformRestrictionsConfiguration' => [
'resource' => 'deviceManagement/deviceEnrollmentConfigurations', 'resource' => 'deviceManagement/deviceEnrollmentConfigurations',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'], 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'],
'allowed_expand' => [], 'allowed_expand' => [],
'type_family' => [ 'type_family' => [
'#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration', '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration',
'#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration', '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration',
'#microsoft.graph.deviceEnrollmentLimitConfiguration',
], ],
'create_method' => 'POST', 'create_method' => 'POST',
'update_method' => 'PATCH', 'update_method' => 'PATCH',
@ -536,6 +551,39 @@
'assignments_create_method' => 'POST', 'assignments_create_method' => 'POST',
'assignments_payload_key' => 'enrollmentConfigurationAssignments', 'assignments_payload_key' => 'enrollmentConfigurationAssignments',
], ],
'deviceEnrollmentNotificationConfiguration' => [
'resource' => 'deviceManagement/deviceEnrollmentConfigurations',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.deviceEnrollmentNotificationConfiguration',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'update_strip_keys' => [
'notificationTemplateSnapshots',
],
'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_payload_key' => 'enrollmentConfigurationAssignments',
],
'enrollmentRestriction' => [
'resource' => 'deviceManagement/deviceEnrollmentConfigurations',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'],
'allowed_expand' => [],
'type_family' => [],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_payload_key' => 'enrollmentConfigurationAssignments',
],
'termsAndConditions' => [ 'termsAndConditions' => [
'resource' => 'deviceManagement/termsAndConditions', 'resource' => 'deviceManagement/termsAndConditions',
'allowed_select' => [ 'allowed_select' => [

View File

@ -185,6 +185,37 @@
'restore' => 'enabled', 'restore' => 'enabled',
'risk' => 'medium', 'risk' => 'medium',
], ],
[
'type' => 'deviceEnrollmentLimitConfiguration',
'label' => 'Enrollment Limits',
'category' => 'Enrollment',
'platform' => 'all',
'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations',
'backup' => 'full',
'restore' => 'preview-only',
'risk' => 'high',
],
[
'type' => 'deviceEnrollmentPlatformRestrictionsConfiguration',
'label' => 'Platform Restrictions (Enrollment)',
'category' => 'Enrollment',
'platform' => 'all',
'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations',
'backup' => 'full',
'restore' => 'preview-only',
'risk' => 'high',
],
[
'type' => 'deviceEnrollmentNotificationConfiguration',
'label' => 'Enrollment Notifications',
'category' => 'Enrollment',
'platform' => 'all',
'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations',
'filter' => "deviceEnrollmentConfigurationType eq 'EnrollmentNotificationsConfiguration'",
'backup' => 'full',
'restore' => 'preview-only',
'risk' => 'high',
],
[ [
'type' => 'enrollmentRestriction', 'type' => 'enrollmentRestriction',
'label' => 'Enrollment Restrictions', 'label' => 'Enrollment Restrictions',

View File

@ -8,21 +8,21 @@ ## Phase 1: Setup
- [x] T001 Create spec/plan/tasks and checklist. - [x] T001 Create spec/plan/tasks and checklist.
## Phase 2: Research & Design ## Phase 2: Research & Design
- [ ] T002 Confirm `@odata.type` for each subtype and whether Graph supports assignments. - [x] T002 Confirm `@odata.type` for each subtype and whether Graph supports assignments.
- [ ] T003 Decide restore modes and risk levels. - [x] T003 Decide restore modes and risk levels.
## Phase 3: Tests (TDD) ## Phase 3: Tests (TDD)
- [ ] T004 Add sync tests ensuring each subtype is classified correctly. - [x] T004 Add sync tests ensuring each subtype is classified correctly.
- [ ] T005 Add snapshot capture test for at least one subtype. - [x] T005 Add snapshot capture test for at least one subtype.
- [ ] T006 Add restore preview test ensuring preview-only behavior. - [x] T006 Add restore preview test ensuring preview-only behavior.
## Phase 4: Implementation ## Phase 4: Implementation
- [ ] T007 Add new types to `config/tenantpilot.php`. - [x] T007 Add new types to `config/tenantpilot.php`.
- [ ] T008 Add contracts in `config/graph_contracts.php` (resource + type families). - [x] T008 Add contracts in `config/graph_contracts.php` (resource + type families).
- [ ] T009 Update `PolicySyncService` enrollment classification logic. - [x] T009 Update `PolicySyncService` enrollment classification logic.
- [ ] T010 Add normalizer for readable UI output (key fields per subtype). - [x] T010 Add normalizer for readable UI output (key fields per subtype).
- [x] T013 Hydrate notification templates for enrollment notifications.
## Phase 5: Verification ## Phase 5: Verification
- [ ] T011 Run targeted tests. - [x] T011 Run targeted tests.
- [ ] T012 Run Pint (`./vendor/bin/pint --dirty`). - [x] T012 Run Pint (`./vendor/bin/pint --dirty`).

View File

@ -109,11 +109,11 @@
$response->assertSee('app-2'); $response->assertSee('app-2');
}); });
test('policy detail renders normalized settings for enrollment restrictions', function () { test('policy detail renders normalized settings for platform restrictions (enrollment)', function () {
$policy = Policy::create([ $policy = Policy::create([
'tenant_id' => $this->tenant->id, 'tenant_id' => $this->tenant->id,
'external_id' => 'enroll-restrict-1', 'external_id' => 'enroll-restrict-1',
'policy_type' => 'enrollmentRestriction', 'policy_type' => 'deviceEnrollmentPlatformRestrictionsConfiguration',
'display_name' => 'Restriction A', 'display_name' => 'Restriction A',
'platform' => 'all', 'platform' => 'all',
]); ]);
@ -143,9 +143,9 @@
$response->assertOk(); $response->assertOk();
$response->assertSee('Settings'); $response->assertSee('Settings');
$response->assertSee('Enrollment restrictions'); $response->assertSee('Platform restrictions (enrollment)');
$response->assertSee('Personal device enrollment blocked'); $response->assertSee('Platform: Personal device enrollment blocked');
$response->assertSee('Enabled'); $response->assertSee('Enabled');
$response->assertSee('Blocked SKUs'); $response->assertSee('Platform: Blocked SKUs');
$response->assertSee('sku-1'); $response->assertSee('sku-1');
}); });

View File

@ -109,3 +109,103 @@ public function request(string $method, string $path, array $options = []): Grap
expect($client->applyCalls)->toBe(0); expect($client->applyCalls)->toBe(0);
}); });
test('enrollment limit restores are preview-only and skipped on execution', function () {
$client = new class implements GraphClientInterface
{
public int $applyCalls = 0;
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(true, ['payload' => []]);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
$this->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-limit',
'name' => 'Tenant Enrollment Limit',
'metadata' => [],
]);
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'enrollment-limit-1',
'policy_type' => 'deviceEnrollmentLimitConfiguration',
'display_name' => 'Enrollment Limit',
'platform' => 'all',
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Enrollment Limit 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.deviceEnrollmentLimitConfiguration',
'id' => $policy->external_id,
'displayName' => $policy->display_name,
'limit' => 5,
],
]);
$service = app(RestoreService::class);
$preview = $service->preview($tenant, $backupSet, [$backupItem->id]);
$previewItem = collect($preview)->first(fn (array $item) => ($item['policy_type'] ?? null) === 'deviceEnrollmentLimitConfiguration');
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);
});

View File

@ -56,3 +56,95 @@
$response->assertSee('Enable feature'); $response->assertSee('Enable feature');
$response->assertSee('Normalized diff'); $response->assertSee('Normalized diff');
}); });
test('policy version detail shows enrollment notification template settings', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-enrollment-notify',
'name' => 'Tenant Enrollment Notify',
'metadata' => [],
'is_current' => true,
]);
$tenant->makeCurrent();
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'enroll-notify-1',
'policy_type' => 'deviceEnrollmentNotificationConfiguration',
'display_name' => 'Enrollment Notifications',
'platform' => 'all',
]);
$version = 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.deviceEnrollmentNotificationConfiguration',
'displayName' => 'Enrollment Notifications',
'priority' => 1,
'version' => 1,
'platformType' => 'windows',
'notificationTemplates' => ['Email_email-template-1', 'Push_push-template-1'],
'notificationTemplateSnapshots' => [
[
'channel' => 'Email',
'template_id' => 'email-template-1',
'template' => [
'id' => 'email-template-1',
'displayName' => 'Email Template',
'defaultLocale' => 'en-us',
'brandingOptions' => 'none',
],
'localized_notification_messages' => [
[
'locale' => 'en-us',
'subject' => 'Email Subject',
'messageTemplate' => 'Email Body',
'isDefault' => true,
],
],
],
[
'channel' => 'Push',
'template_id' => 'push-template-1',
'template' => [
'id' => 'push-template-1',
'displayName' => 'Push Template',
'defaultLocale' => 'en-us',
'brandingOptions' => 'none',
],
'localized_notification_messages' => [
[
'locale' => 'en-us',
'subject' => 'Push Subject',
'messageTemplate' => 'Push Body',
'isDefault' => true,
],
],
],
],
],
]);
$user = User::factory()->create();
$response = $this->actingAs($user)
->get(PolicyVersionResource::getUrl('view', ['record' => $version]).'?tab=normalized-settings');
$response->assertOk();
$response->assertSee('Enrollment notifications');
$response->assertSee('Notification templates');
$response->assertSee('Email (en-us) Subject');
$response->assertSee('Email Subject');
$response->assertSee('Email (en-us) Message');
$response->assertSee('Email Body');
$response->assertSee('Push (en-us) Subject');
$response->assertSee('Push Subject');
$response->assertSee('Push (en-us) Message');
$response->assertSee('Push Body');
});

View File

@ -172,7 +172,39 @@ public function request(string $method, string $path, array $options = []): Grap
$response = $this->get(route('filament.admin.resources.tenants.view', $tenant)); $response = $this->get(route('filament.admin.resources.tenants.view', $tenant));
$response->assertOk(); $response->assertOk();
$response->assertSee('Actions');
$response->assertSee($firstKey); $response->assertSee($firstKey);
$response->assertSee('ok'); $response->assertSee('ok');
$response->assertSee('missing'); $response->assertSee('missing');
}); });
test('tenant list shows Open in Entra action', function () {
$user = User::factory()->create();
$this->actingAs($user);
Tenant::create([
'tenant_id' => 'tenant-ui-list',
'name' => 'UI Tenant List',
'app_client_id' => 'client-123',
]);
$response = $this->get(route('filament.admin.resources.tenants.index'));
$response->assertOk();
$response->assertSee('Open in Entra');
});
test('tenant can be deactivated from the tenant detail action menu', function () {
$user = User::factory()->create();
$this->actingAs($user);
$tenant = Tenant::create([
'tenant_id' => 'tenant-ui-deactivate',
'name' => 'UI Tenant Deactivate',
]);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->callAction('archive');
$this->assertSoftDeleted('tenants', ['id' => $tenant->id]);
});

View File

@ -106,7 +106,11 @@
$mock->shouldReceive('listPolicies') $mock->shouldReceive('listPolicies')
->andReturnUsing(function (string $policyType) use ($payload) { ->andReturnUsing(function (string $policyType) use ($payload) {
if (in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) { if (in_array($policyType, [
'enrollmentRestriction',
'windowsEnrollmentStatusPage',
'deviceEnrollmentPlatformRestrictionsConfiguration',
], true)) {
return new GraphResponse(true, $payload); return new GraphResponse(true, $payload);
} }
@ -122,6 +126,11 @@
'platform' => 'all', 'platform' => 'all',
'filter' => null, 'filter' => null,
], ],
[
'type' => 'deviceEnrollmentPlatformRestrictionsConfiguration',
'platform' => 'all',
'filter' => null,
],
[ [
'type' => 'enrollmentRestriction', 'type' => 'enrollmentRestriction',
'platform' => 'all', 'platform' => 'all',
@ -142,6 +151,100 @@
->pluck('external_id') ->pluck('external_id')
->all(); ->all();
$platformRestrictionIds = Policy::query()
->where('tenant_id', $tenant->id)
->where('policy_type', 'deviceEnrollmentPlatformRestrictionsConfiguration')
->orderBy('external_id')
->pluck('external_id')
->all();
expect($espIds)->toMatchArray(['esp-1']); expect($espIds)->toMatchArray(['esp-1']);
expect($restrictionIds)->toMatchArray(['other-1', 'restriction-1']); expect($platformRestrictionIds)->toMatchArray(['restriction-1']);
expect($restrictionIds)->toMatchArray(['other-1']);
});
test('policy sync classifies enrollment configuration subtypes separately', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-sync-enrollment-subtypes',
'name' => 'Tenant Sync Enrollment Subtypes',
'metadata' => [],
'is_current' => true,
]);
$tenant->makeCurrent();
$this->mock(GraphClientInterface::class, function (MockInterface $mock) {
$limitPayload = [
'id' => 'limit-1',
'displayName' => 'Enrollment Limit',
'@odata.type' => '#microsoft.graph.deviceEnrollmentLimitConfiguration',
'deviceEnrollmentConfigurationType' => 'deviceEnrollmentLimitConfiguration',
'limit' => 5,
];
$platformRestrictionsPayload = [
'id' => 'platform-1',
'displayName' => 'Platform Restrictions',
'@odata.type' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration',
'deviceEnrollmentConfigurationType' => 'deviceEnrollmentPlatformRestrictionsConfiguration',
];
$notificationPayload = [
'id' => 'notify-1',
'displayName' => 'Enrollment Notifications',
'@odata.type' => '#microsoft.graph.deviceEnrollmentNotificationConfiguration',
'deviceEnrollmentConfigurationType' => 'EnrollmentNotificationsConfiguration',
];
$unfilteredPayload = [
$limitPayload,
$platformRestrictionsPayload,
$notificationPayload,
];
$mock->shouldReceive('listPolicies')
->andReturnUsing(function (string $policyType) use ($notificationPayload, $unfilteredPayload) {
if ($policyType === 'deviceEnrollmentNotificationConfiguration') {
return new GraphResponse(true, [$notificationPayload]);
}
if (in_array($policyType, [
'enrollmentRestriction',
'deviceEnrollmentLimitConfiguration',
'deviceEnrollmentPlatformRestrictionsConfiguration',
'windowsEnrollmentStatusPage',
], true)) {
return new GraphResponse(true, $unfilteredPayload);
}
return new GraphResponse(true, []);
});
});
$service = app(PolicySyncService::class);
$service->syncPolicies($tenant, [
['type' => 'deviceEnrollmentLimitConfiguration', 'platform' => 'all', 'filter' => null],
['type' => 'deviceEnrollmentPlatformRestrictionsConfiguration', 'platform' => 'all', 'filter' => null],
['type' => 'deviceEnrollmentNotificationConfiguration', 'platform' => 'all', 'filter' => null],
['type' => 'enrollmentRestriction', 'platform' => 'all', 'filter' => null],
]);
expect(Policy::query()
->where('tenant_id', $tenant->id)
->where('policy_type', 'deviceEnrollmentLimitConfiguration')
->pluck('external_id')
->all())->toMatchArray(['limit-1']);
expect(Policy::query()
->where('tenant_id', $tenant->id)
->where('policy_type', 'deviceEnrollmentPlatformRestrictionsConfiguration')
->pluck('external_id')
->all())->toMatchArray(['platform-1']);
expect(Policy::query()
->where('tenant_id', $tenant->id)
->where('policy_type', 'deviceEnrollmentNotificationConfiguration')
->pluck('external_id')
->all())->toMatchArray(['notify-1']);
}); });

View File

@ -98,6 +98,76 @@
expect($version->assignments[0]['target']['assignment_filter_name'])->toBe('Targeted Devices'); expect($version->assignments[0]['target']['assignment_filter_name'])->toBe('Targeted Devices');
}); });
it('captures enrollment limit configuration version with assignments from graph', function () {
$this->policy->forceFill([
'policy_type' => 'deviceEnrollmentLimitConfiguration',
'platform' => 'all',
])->save();
$this->mock(PolicySnapshotService::class, function ($mock) {
$mock->shouldReceive('fetch')
->once()
->andReturn([
'payload' => [
'@odata.type' => '#microsoft.graph.deviceEnrollmentLimitConfiguration',
'id' => 'test-policy-id',
'displayName' => 'Enrollment Limit',
'limit' => 5,
],
]);
});
$this->mock(AssignmentFetcher::class, function ($mock) {
$mock->shouldReceive('fetch')
->once()
->withArgs(function (string $policyType): bool {
return $policyType === 'deviceEnrollmentLimitConfiguration';
})
->andReturn([
[
'id' => 'assignment-1',
'intent' => 'apply',
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'group-123',
],
],
]);
});
$this->mock(GroupResolver::class, function ($mock) {
$mock->shouldReceive('resolveGroupIds')
->once()
->andReturn([
'group-123' => [
'id' => 'group-123',
'displayName' => 'Test Group',
'orphaned' => false,
],
]);
});
$this->mock(AssignmentFilterResolver::class, function ($mock) {
$mock->shouldReceive('resolve')
->once()
->andReturn([]);
});
$versionService = app(VersionService::class);
$version = $versionService->captureFromGraph(
$this->tenant,
$this->policy,
'test@example.com'
);
expect($version)->not->toBeNull()
->and($version->policy_type)->toBe('deviceEnrollmentLimitConfiguration')
->and($version->snapshot['@odata.type'] ?? null)->toBe('#microsoft.graph.deviceEnrollmentLimitConfiguration')
->and($version->snapshot['limit'] ?? null)->toBe(5)
->and($version->assignments)->toHaveCount(1)
->and($version->metadata['assignments_count'])->toBe(1);
});
it('hydrates assignment filter names when filter data is stored at root', function () { it('hydrates assignment filter names when filter data is stored at root', function () {
$this->mock(PolicySnapshotService::class, function ($mock) { $this->mock(PolicySnapshotService::class, function ($mock) {
$mock->shouldReceive('fetch') $mock->shouldReceive('fetch')

View File

@ -67,34 +67,30 @@
expect(collect($result['warnings'])->join(' '))->toContain('@odata.type mismatch'); expect(collect($result['warnings'])->join(' '))->toContain('@odata.type mismatch');
}); });
it('normalizes enrollment restrictions platform restriction payload', function () { it('normalizes enrollment platform restriction payload', function () {
$snapshot = [ $snapshot = [
'@odata.type' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration', '@odata.type' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration',
'deviceEnrollmentConfigurationType' => 'deviceEnrollmentPlatformRestrictionConfiguration', 'deviceEnrollmentConfigurationType' => 'deviceEnrollmentPlatformRestrictionConfiguration',
'displayName' => 'DeviceTypeRestriction', 'displayName' => 'DeviceTypeRestriction',
'version' => 2, 'version' => 2,
// Graph uses this singular shape for platform restriction configs.
'platformRestriction' => [ 'platformRestriction' => [
'platformBlocked' => false, 'platformBlocked' => false,
'personalDeviceEnrollmentBlocked' => true, 'personalDeviceEnrollmentBlocked' => true,
], ],
]; ];
$result = $this->normalizer->normalize($snapshot, 'enrollmentRestriction', 'all'); $result = $this->normalizer->normalize($snapshot, 'deviceEnrollmentPlatformRestrictionsConfiguration', 'all');
$block = collect($result['settings'])->firstWhere('title', 'Enrollment restrictions'); $block = collect($result['settings'])->firstWhere('title', 'Platform restrictions (enrollment)');
expect($block)->not->toBeNull(); expect($block)->not->toBeNull();
$platformEntry = collect($block['entries'] ?? [])->firstWhere('key', 'Platform restrictions'); expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform: Platform blocked')['value'] ?? null)->toBe('Disabled');
expect($platformEntry)->toBeNull(); expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform: Personal device enrollment blocked')['value'] ?? null)->toBe('Enabled');
expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform blocked')['value'] ?? null)->toBe('Disabled'); expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform: OS minimum version')['value'] ?? null)->toBe('None');
expect(collect($block['entries'] ?? [])->firstWhere('key', 'Personal device enrollment blocked')['value'] ?? null)->toBe('Enabled'); expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform: OS maximum version')['value'] ?? null)->toBe('None');
expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform: Blocked manufacturers')['value'] ?? null)->toBe(['None']);
expect(collect($block['entries'] ?? [])->firstWhere('key', 'OS minimum version')['value'] ?? null)->toBe('None'); expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform: Blocked SKUs')['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 () { it('normalizes Autopilot deployment profile key fields', function () {

View File

@ -172,6 +172,120 @@ public function request(string $method, string $path, array $options = []): Grap
} }
} }
class EnrollmentNotificationSnapshotGraphClient implements GraphClientInterface
{
public array $requests = [];
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(success: true, data: []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
$this->requests[] = ['getPolicy', $policyType, $policyId, $options];
if ($policyType === 'deviceEnrollmentNotificationConfiguration') {
return new GraphResponse(success: true, data: [
'payload' => [
'id' => $policyId,
'displayName' => 'Enrollment Notifications',
'@odata.type' => '#microsoft.graph.deviceEnrollmentNotificationConfiguration',
'priority' => 1,
'version' => 1,
'platformType' => 'windows',
'brandingOptions' => 'none',
'templateType' => '0',
'notificationMessageTemplateId' => '00000000-0000-0000-0000-000000000000',
'notificationTemplates' => [
'Email_email-template-1',
'Push_push-template-1',
],
'deviceEnrollmentConfigurationType' => 'enrollmentNotificationsConfiguration',
],
]);
}
return new GraphResponse(success: true, data: [
'payload' => [
'id' => $policyId,
'displayName' => 'Policy',
'@odata.type' => '#microsoft.graph.deviceConfiguration',
],
]);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(success: true, data: []);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return new GraphResponse(success: true, data: []);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(success: true, data: []);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
$this->requests[] = [$method, $path, $options];
if ($method === 'GET' && str_contains($path, 'deviceManagement/notificationMessageTemplates/email-template-1/localizedNotificationMessages')) {
return new GraphResponse(success: true, data: [
'value' => [
[
'id' => 'email-template-1_en-us',
'locale' => 'en-us',
'subject' => 'Email Subject',
'messageTemplate' => 'Email Body',
'isDefault' => true,
],
],
]);
}
if ($method === 'GET' && str_contains($path, 'deviceManagement/notificationMessageTemplates/push-template-1/localizedNotificationMessages')) {
return new GraphResponse(success: true, data: [
'value' => [
[
'id' => 'push-template-1_en-us',
'locale' => 'en-us',
'subject' => 'Push Subject',
'messageTemplate' => 'Push Body',
'isDefault' => true,
],
],
]);
}
if ($method === 'GET' && str_contains($path, 'deviceManagement/notificationMessageTemplates/email-template-1')) {
return new GraphResponse(success: true, data: [
'@odata.context' => 'https://graph.microsoft.com/beta/$metadata#deviceManagement/notificationMessageTemplates/$entity',
'id' => 'email-template-1',
'displayName' => 'Email Template',
'defaultLocale' => 'en-us',
'brandingOptions' => 'none',
]);
}
if ($method === 'GET' && str_contains($path, 'deviceManagement/notificationMessageTemplates/push-template-1')) {
return new GraphResponse(success: true, data: [
'@odata.context' => 'https://graph.microsoft.com/beta/$metadata#deviceManagement/notificationMessageTemplates/$entity',
'id' => 'push-template-1',
'displayName' => 'Push Template',
'defaultLocale' => 'en-us',
'brandingOptions' => 'none',
]);
}
return new GraphResponse(success: true, data: []);
}
}
it('hydrates compliance policy scheduled actions into snapshots', function () { it('hydrates compliance policy scheduled actions into snapshots', function () {
$client = new PolicySnapshotGraphClient; $client = new PolicySnapshotGraphClient;
app()->instance(GraphClientInterface::class, $client); app()->instance(GraphClientInterface::class, $client);
@ -247,6 +361,47 @@ public function request(string $method, string $path, array $options = []): Grap
'securityBaselinePolicy', 'securityBaselinePolicy',
]); ]);
it('hydrates enrollment notification templates into snapshots', function () {
$client = new EnrollmentNotificationSnapshotGraphClient;
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-enrollment-notifications',
'app_client_id' => 'client-123',
'app_client_secret' => 'secret-123',
'is_current' => true,
]);
$tenant->makeCurrent();
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'enroll-notify-123',
'policy_type' => 'deviceEnrollmentNotificationConfiguration',
'display_name' => 'Enrollment Notifications',
'platform' => 'all',
]);
$service = app(PolicySnapshotService::class);
$result = $service->fetch($tenant, $policy);
expect($result)->toHaveKey('payload');
expect($result['payload'])->toHaveKey('notificationTemplateSnapshots');
expect($result['payload']['notificationTemplateSnapshots'])->toHaveCount(2);
expect($result['metadata']['enrollment_notification_templates_hydration'] ?? null)->toBe('complete');
$email = collect($result['payload']['notificationTemplateSnapshots'])->firstWhere('channel', 'Email');
expect($email)->not->toBeNull()
->and($email['template_id'] ?? null)->toBe('email-template-1')
->and($email['template']['displayName'] ?? null)->toBe('Email Template')
->and($email['localized_notification_messages'][0]['subject'] ?? null)->toBe('Email Subject');
$push = collect($result['payload']['notificationTemplateSnapshots'])->firstWhere('channel', 'Push');
expect($push)->not->toBeNull()
->and($push['template_id'] ?? null)->toBe('push-template-1')
->and($push['template']['displayName'] ?? null)->toBe('Push Template')
->and($push['localized_notification_messages'][0]['subject'] ?? null)->toBe('Push Subject');
});
it('filters mobile app snapshots to metadata-only keys', function () { it('filters mobile app snapshots to metadata-only keys', function () {
$client = new PolicySnapshotGraphClient; $client = new PolicySnapshotGraphClient;
app()->instance(GraphClientInterface::class, $client); app()->instance(GraphClientInterface::class, $client);