feat: sync endpoint security policies and improve settings display

This commit is contained in:
Ahmed Darrazi 2026-01-03 02:55:55 +01:00
parent cd73d7e944
commit aa398770eb
39 changed files with 3024 additions and 101 deletions

View File

@ -58,6 +58,26 @@ public static function infolist(Schema $schema): Schema
TextEntry::make('external_id')->label('External ID'),
TextEntry::make('last_synced_at')->dateTime()->label('Last synced'),
TextEntry::make('created_at')->since(),
TextEntry::make('latest_snapshot_mode')
->label('Snapshot')
->badge()
->color(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'warning' : 'success')
->state(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'metadata only' : 'full')
->helperText(function (Policy $record): ?string {
$meta = static::latestVersionMetadata($record);
if (($meta['source'] ?? null) !== 'metadata_only') {
return null;
}
$status = $meta['original_status'] ?? null;
return sprintf(
'Graph returned %s for this policy type. Only local metadata was saved; settings and restore are unavailable until Graph works again.',
$status ?? 'an error'
);
})
->visible(fn (Policy $record) => $record->versions()->exists()),
])
->columns(2)
->columnSpanFull(),
@ -597,6 +617,20 @@ private static function latestSnapshot(Policy $record): array
return [];
}
private static function latestVersionMetadata(Policy $record): array
{
$metadata = $record->relationLoaded('versions')
? $record->versions->first()?->metadata
: $record->versions()->orderByDesc('captured_at')->value('metadata');
if (is_string($metadata)) {
$decoded = json_decode($metadata, true);
$metadata = $decoded ?? [];
}
return is_array($metadata) ? $metadata : [];
}
/**
* @return array<string, mixed>
*/
@ -764,7 +798,7 @@ private static function settingsTabState(Policy $record): array
$rows = $normalized['settings_table']['rows'] ?? [];
$hasSettingsTable = is_array($rows) && $rows !== [];
if ($record->policy_type === 'settingsCatalogPolicy' && $hasSettingsTable) {
if (in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true) && $hasSettingsTable) {
$split = static::splitGeneralBlock($normalized);
return $split['normalized'];

View File

@ -28,11 +28,35 @@ protected function getHeaderActions(): array
/** @var PolicySyncService $service */
$service = app(PolicySyncService::class);
$synced = $service->syncPolicies($tenant);
$result = $service->syncPoliciesWithReport($tenant);
$syncedCount = count($result['synced'] ?? []);
$failureCount = count($result['failures'] ?? []);
$body = $syncedCount.' policies synced';
if ($failureCount > 0) {
$first = $result['failures'][0] ?? [];
$firstType = $first['policy_type'] ?? 'unknown';
$firstStatus = $first['status'] ?? null;
$firstErrorMessage = null;
$firstErrors = $first['errors'] ?? null;
if (is_array($firstErrors) && isset($firstErrors[0]) && is_array($firstErrors[0])) {
$firstErrorMessage = $firstErrors[0]['message'] ?? null;
}
$suffix = $firstStatus ? "first: {$firstType} {$firstStatus}" : "first: {$firstType}";
if (is_string($firstErrorMessage) && $firstErrorMessage !== '') {
$suffix .= ' - '.trim($firstErrorMessage);
}
$body .= " ({$failureCount} failed; {$suffix})";
}
Notification::make()
->title('Policy sync completed')
->body(count($synced).' policies synced')
->body($body)
->success()
->sendToDatabase(auth()->user())
->send();

View File

@ -49,7 +49,7 @@ protected function getActions(): array
return;
}
app(VersionService::class)->captureFromGraph(
$version = app(VersionService::class)->captureFromGraph(
tenant: $tenant,
policy: $policy,
createdBy: auth()->user()?->email ?? null,
@ -57,10 +57,23 @@ protected function getActions(): array
includeScopeTags: $data['include_scope_tags'] ?? false,
);
Notification::make()
->title('Snapshot captured successfully.')
->success()
->send();
if (($version->metadata['source'] ?? null) === 'metadata_only') {
$status = $version->metadata['original_status'] ?? null;
Notification::make()
->title('Snapshot captured (metadata only)')
->body(sprintf(
'Microsoft Graph returned %s for this policy type, so only local metadata was saved. Full restore is not possible until Graph works again.',
$status ?? 'an error'
))
->warning()
->send();
} else {
Notification::make()
->title('Snapshot captured successfully.')
->success()
->send();
}
$this->redirect($this->getResource()::getUrl('view', ['record' => $policy->getKey()]));
} catch (\Throwable $e) {

View File

@ -34,6 +34,8 @@ public function table(Table $table): Table
->label('Restore to Intune')
->icon('heroicon-o-arrow-path-rounded-square')
->color('danger')
->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only')
->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).')
->requiresConfirmation()
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
->modalSubheading('Creates a restore run using this policy version snapshot.')

View File

@ -74,7 +74,7 @@ public static function infolist(Schema $schema): Schema
return $normalized;
})
->visible(fn (PolicyVersion $record) => ($record->policy_type ?? '') === 'settingsCatalogPolicy'),
->visible(fn (PolicyVersion $record) => in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)),
Infolists\Components\ViewEntry::make('normalized_settings_standard')
->view('filament.infolists.entries.policy-settings-standard')
@ -91,7 +91,7 @@ public static function infolist(Schema $schema): Schema
return $normalized;
})
->visible(fn (PolicyVersion $record) => ($record->policy_type ?? '') !== 'settingsCatalogPolicy'),
->visible(fn (PolicyVersion $record) => ! in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)),
]),
Tab::make('Raw JSON')
->id('raw-json')
@ -194,6 +194,8 @@ public static function table(Table $table): Table
->label('Restore via Wizard')
->icon('heroicon-o-arrow-path-rounded-square')
->color('primary')
->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only')
->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).')
->requiresConfirmation()
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')

View File

@ -175,6 +175,9 @@ public function table(Table $table): Table
$backupSet = BackupSet::query()->findOrFail($this->backupSetId);
$tenant = $backupSet->tenant ?? Tenant::current();
$beforeFailures = (array) (($backupSet->metadata ?? [])['failures'] ?? []);
$beforeFailureCount = count($beforeFailures);
$policyIds = $records->pluck('id')->all();
if ($policyIds === []) {
@ -201,10 +204,23 @@ public function table(Table $table): Table
? 'Backup items added'
: 'Policies added to backup';
Notification::make()
->title($notificationTitle)
->success()
->send();
$backupSet->refresh();
$afterFailures = (array) (($backupSet->metadata ?? [])['failures'] ?? []);
$afterFailureCount = count($afterFailures);
if ($afterFailureCount > $beforeFailureCount) {
Notification::make()
->title($notificationTitle.' with failures')
->body('Some policies could not be captured from Microsoft Graph. Check the backup set failures list for details.')
->warning()
->send();
} else {
Notification::make()
->title($notificationTitle)
->success()
->send();
}
$this->resetTable();
}),

View File

@ -10,6 +10,7 @@
use App\Services\Intune\DeviceConfigurationPolicyNormalizer;
use App\Services\Intune\EnrollmentAutopilotPolicyNormalizer;
use App\Services\Intune\GroupPolicyConfigurationNormalizer;
use App\Services\Intune\ManagedDeviceAppConfigurationNormalizer;
use App\Services\Intune\ScriptsPolicyNormalizer;
use App\Services\Intune\SettingsCatalogPolicyNormalizer;
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
@ -45,6 +46,7 @@ public function register(): void
DeviceConfigurationPolicyNormalizer::class,
EnrollmentAutopilotPolicyNormalizer::class,
GroupPolicyConfigurationNormalizer::class,
ManagedDeviceAppConfigurationNormalizer::class,
ScriptsPolicyNormalizer::class,
SettingsCatalogPolicyNormalizer::class,
WindowsFeatureUpdateProfileNormalizer::class,

View File

@ -32,6 +32,16 @@ public function sanitizeQuery(string $policyType, array $query): array
: array_map('trim', explode(',', (string) $original));
$filtered = array_values(array_intersect($select, $allowedSelect));
$withoutAnnotations = array_values(array_filter(
$filtered,
static fn ($field) => is_string($field) && ! str_contains($field, '@')
));
if (count($withoutAnnotations) !== count($filtered)) {
$warnings[] = 'Removed OData annotation fields from $select (unsupported by Graph).';
$filtered = $withoutAnnotations;
}
if (count($filtered) !== count($select)) {
$warnings[] = 'Trimmed unsupported $select fields for capability safety.';
}

View File

@ -14,6 +14,8 @@ class MicrosoftGraphClient implements GraphClientInterface
{
private const DEFAULT_SCOPE = 'https://graph.microsoft.com/.default';
private const MAX_LIST_PAGES = 50;
private string $baseUrl;
private string $tokenUrlTemplate;
@ -51,12 +53,21 @@ public function __construct(
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
$endpoint = $this->endpointFor($policyType);
$query = array_filter([
$contract = $this->contracts->get($policyType);
$allowedSelect = is_array($contract['allowed_select'] ?? null) ? $contract['allowed_select'] : [];
$defaultSelect = $options['select'] ?? ($allowedSelect !== [] ? implode(',', $allowedSelect) : null);
$queryInput = array_filter([
'$top' => $options['top'] ?? null,
'$filter' => $options['filter'] ?? null,
'$select' => $defaultSelect,
'platform' => $options['platform'] ?? null,
], fn ($value) => $value !== null && $value !== '');
$sanitized = $this->contracts->sanitizeQuery($policyType, $queryInput);
$query = $sanitized['query'];
$warnings = $sanitized['warnings'];
$context = $this->resolveContext($options);
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
$fullPath = $this->buildFullPath($endpoint, $query);
@ -79,19 +90,178 @@ public function listPolicies(string $policyType, array $options = []): GraphResp
$response = $this->send('GET', $endpoint, $sendOptions, $context);
return $this->toGraphResponse(
action: 'list_policies',
response: $response,
transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []),
meta: [
'tenant' => $context['tenant'] ?? null,
'path' => $endpoint,
'full_path' => $fullPath,
if ($response->failed()) {
$graphResponse = $this->toGraphResponse(
action: 'list_policies',
response: $response,
transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []),
meta: [
'tenant' => $context['tenant'] ?? null,
'path' => $endpoint,
'full_path' => $fullPath,
'method' => 'GET',
'query' => $query ?: null,
'client_request_id' => $clientRequestId,
],
warnings: $warnings,
);
if (! $this->shouldApplySelectFallback($graphResponse, $query)) {
return $graphResponse;
}
$fallbackQuery = array_filter($query, fn ($value, $key) => $key !== '$select', ARRAY_FILTER_USE_BOTH);
$fallbackPath = $this->buildFullPath($endpoint, $fallbackQuery);
$fallbackSendOptions = ['query' => $fallbackQuery, 'client_request_id' => $clientRequestId];
if (isset($options['access_token'])) {
$fallbackSendOptions['access_token'] = $options['access_token'];
}
$this->logger->logRequest('list_policies_fallback', [
'endpoint' => $endpoint,
'full_path' => $fallbackPath,
'method' => 'GET',
'query' => $query ?: null,
'policy_type' => $policyType,
'tenant' => $context['tenant'],
'query' => $fallbackQuery ?: null,
'client_request_id' => $clientRequestId,
]
]);
$fallbackResponse = $this->send('GET', $endpoint, $fallbackSendOptions, $context);
if ($fallbackResponse->failed()) {
return $this->toGraphResponse(
action: 'list_policies',
response: $fallbackResponse,
transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []),
meta: [
'tenant' => $context['tenant'] ?? null,
'path' => $endpoint,
'full_path' => $fallbackPath,
'method' => 'GET',
'query' => $fallbackQuery ?: null,
'client_request_id' => $clientRequestId,
],
warnings: array_values(array_unique(array_merge(
$warnings,
['Capability fallback applied: removed $select for compatibility.']
))),
);
}
$response = $fallbackResponse;
$query = $fallbackQuery;
$fullPath = $fallbackPath;
$warnings = array_values(array_unique(array_merge(
$warnings,
['Capability fallback applied: removed $select for compatibility.']
)));
}
$json = $response->json() ?? [];
$policies = $json['value'] ?? (is_array($json) ? $json : []);
$nextLink = $json['@odata.nextLink'] ?? null;
$pages = 1;
while (is_string($nextLink) && $nextLink !== '') {
if ($pages >= self::MAX_LIST_PAGES) {
$graphResponse = new GraphResponse(
success: false,
data: [],
status: 500,
errors: [[
'message' => 'Graph pagination exceeded maximum page limit.',
'max_pages' => self::MAX_LIST_PAGES,
]],
warnings: $warnings,
meta: [
'tenant' => $context['tenant'] ?? null,
'path' => $endpoint,
'full_path' => $fullPath,
'method' => 'GET',
'query' => $query ?: null,
'client_request_id' => $clientRequestId,
'pages_fetched' => $pages,
],
);
$this->logger->logResponse('list_policies', $graphResponse, $graphResponse->meta);
return $graphResponse;
}
$pageOptions = ['client_request_id' => $clientRequestId];
if (isset($options['access_token'])) {
$pageOptions['access_token'] = $options['access_token'];
}
$pageResponse = $this->send('GET', $nextLink, $pageOptions, $context);
if ($pageResponse->failed()) {
$graphResponse = $this->toGraphResponse(
action: 'list_policies',
response: $pageResponse,
transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []),
meta: [
'tenant' => $context['tenant'] ?? null,
'path' => $endpoint,
'full_path' => $fullPath,
'method' => 'GET',
'query' => $query ?: null,
'client_request_id' => $clientRequestId,
'pages_fetched' => $pages,
],
warnings: array_values(array_unique(array_merge(
$warnings,
['Pagination failed while listing policies.']
))),
);
return $graphResponse;
}
$pageJson = $pageResponse->json() ?? [];
$pageValue = $pageJson['value'] ?? [];
if (is_array($pageValue) && $pageValue !== []) {
$policies = array_merge($policies, $pageValue);
}
$nextLink = $pageJson['@odata.nextLink'] ?? null;
$pages++;
}
$meta = $this->responseMeta($response, [
'tenant' => $context['tenant'] ?? null,
'path' => $endpoint,
'full_path' => $fullPath,
'method' => 'GET',
'query' => $query ?: null,
'client_request_id' => $clientRequestId,
]);
$meta['pages_fetched'] = $pages;
$meta['item_count'] = count($policies);
if ($pages > 1) {
$warnings = array_values(array_unique(array_merge($warnings, [
sprintf('Pagination applied: fetched %d pages.', $pages),
])));
}
$graphResponse = new GraphResponse(
success: true,
data: $policies,
status: $response->status(),
warnings: $warnings,
meta: $meta,
);
$this->logger->logResponse('list_policies', $graphResponse, $meta);
return $graphResponse;
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
@ -182,6 +352,37 @@ public function getPolicy(string $policyType, string $policyId, array $options =
return $graphResponse;
}
private function shouldApplySelectFallback(GraphResponse $graphResponse, array $query): bool
{
if (! $graphResponse->failed()) {
return false;
}
if (($graphResponse->status ?? null) !== 400) {
return false;
}
if (! array_key_exists('$select', $query)) {
return false;
}
$errorMessage = $graphResponse->meta['error_message'] ?? null;
if (! is_string($errorMessage) || $errorMessage === '') {
return false;
}
if (stripos($errorMessage, 'Parsing OData Select and Expand failed') !== false) {
return true;
}
if (stripos($errorMessage, 'Could not find a property named') !== false) {
return true;
}
return false;
}
public function getOrganization(array $options = []): GraphResponse
{
$context = $this->resolveContext($options);
@ -575,6 +776,11 @@ private function normalizeScopes(array|string|null $scope): array
private function endpointFor(string $policyType): string
{
$contractResource = $this->contracts->resourcePath($policyType);
if (is_string($contractResource) && $contractResource !== '') {
return $contractResource;
}
$supported = config('tenantpilot.supported_policy_types', []);
foreach ($supported as $type) {
if (($type['type'] ?? null) === $policyType && ! empty($type['endpoint'])) {

View File

@ -35,6 +35,8 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor
$resultWarnings = [];
$status = 'success';
$settingsTable = null;
$usesSettingsCatalogTable = in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true);
$fallbackCategoryName = $this->extractConfigurationPolicyFallbackCategoryName($snapshot);
$validation = $this->validator->validate($snapshot);
$resultWarnings = array_merge($resultWarnings, $validation['warnings']);
@ -60,23 +62,30 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor
}
if (isset($snapshot['settings']) && is_array($snapshot['settings'])) {
if ($policyType === 'settingsCatalogPolicy') {
$normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settings']);
if ($usesSettingsCatalogTable) {
$normalized = $this->buildSettingsCatalogSettingsTable(
$snapshot['settings'],
fallbackCategoryName: $fallbackCategoryName
);
$settingsTable = $normalized['table'];
$resultWarnings = array_merge($resultWarnings, $normalized['warnings']);
} else {
$settings[] = $this->normalizeSettingsCatalog($snapshot['settings']);
}
} elseif (isset($snapshot['settingsDelta']) && is_array($snapshot['settingsDelta'])) {
if ($policyType === 'settingsCatalogPolicy') {
$normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settingsDelta'], 'Settings delta');
if ($usesSettingsCatalogTable) {
$normalized = $this->buildSettingsCatalogSettingsTable(
$snapshot['settingsDelta'],
'Settings delta',
$fallbackCategoryName
);
$settingsTable = $normalized['table'];
$resultWarnings = array_merge($resultWarnings, $normalized['warnings']);
} else {
$settings[] = $this->normalizeSettingsCatalog($snapshot['settingsDelta'], 'Settings delta');
}
} elseif ($policyType === 'settingsCatalogPolicy') {
$resultWarnings[] = 'Settings not hydrated for this Settings Catalog policy.';
} elseif ($usesSettingsCatalogTable) {
$resultWarnings[] = 'Settings not hydrated for this Configuration Policy.';
}
$settings[] = $this->normalizeStandard($snapshot);
@ -231,13 +240,41 @@ private function normalizeSettingsCatalog(array $settings, string $title = 'Sett
];
}
private function extractConfigurationPolicyFallbackCategoryName(array $snapshot): ?string
{
$templateReference = $snapshot['templateReference'] ?? null;
if (is_string($templateReference)) {
$decoded = json_decode($templateReference, true);
$templateReference = is_array($decoded) ? $decoded : null;
}
if (! is_array($templateReference)) {
return null;
}
$displayName = $templateReference['templateDisplayName'] ?? null;
if (is_string($displayName) && $displayName !== '') {
return $displayName;
}
$family = $templateReference['templateFamily'] ?? null;
if (is_string($family) && $family !== '') {
return Str::headline($family);
}
return null;
}
/**
* @param array<int, mixed> $settings
* @return array{table: array<string, mixed>, warnings: array<int, string>}
*/
private function buildSettingsCatalogSettingsTable(array $settings, string $title = 'Settings'): array
private function buildSettingsCatalogSettingsTable(array $settings, string $title = 'Settings', ?string $fallbackCategoryName = null): array
{
$flattened = $this->flattenSettingsCatalogSettingInstances($settings);
$flattened = $this->flattenSettingsCatalogSettingInstances($settings, $fallbackCategoryName);
return [
'table' => [
@ -252,7 +289,7 @@ private function buildSettingsCatalogSettingsTable(array $settings, string $titl
* @param array<int, mixed> $settings
* @return array{rows: array<int, array<string, mixed>>, warnings: array<int, string>}
*/
private function flattenSettingsCatalogSettingInstances(array $settings): array
private function flattenSettingsCatalogSettingInstances(array $settings, ?string $fallbackCategoryName = null): array
{
$rows = [];
$warnings = [];
@ -292,7 +329,8 @@ private function flattenSettingsCatalogSettingInstances(array $settings): array
&$warnedRowLimit,
$definitions,
$categories,
$defaultCategoryName
$defaultCategoryName,
$fallbackCategoryName,
): void {
if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) {
if (! $warnedRowLimit) {
@ -364,6 +402,16 @@ private function flattenSettingsCatalogSettingInstances(array $settings): array
$categoryName = $defaultCategoryName;
}
if (
$categoryName === '-'
&& is_string($fallbackCategoryName)
&& $fallbackCategoryName !== ''
&& is_array($definition)
&& ($definition['isFallback'] ?? false)
) {
$categoryName = $fallbackCategoryName;
}
// Convert technical type to user-friendly data type
$dataType = $this->getUserFriendlyDataType($rawInstanceType, $value);
@ -516,11 +564,41 @@ private function extractSettingsCatalogValue(array $setting, ?array $instance):
$type = $instance['@odata.type'] ?? null;
$type = is_string($type) ? $type : '';
if (Str::contains($type, 'ChoiceSettingCollectionInstance', ignoreCase: true)) {
$collection = $instance['choiceSettingCollectionValue'] ?? null;
if (! is_array($collection) || $collection === []) {
return [];
}
$values = [];
foreach ($collection as $item) {
if (! is_array($item)) {
continue;
}
$value = $item['value'] ?? null;
if (is_string($value) && $value !== '') {
$values[] = $value;
}
}
return array_values(array_unique($values));
}
if (Str::contains($type, 'SimpleSettingInstance', ignoreCase: true)) {
$simple = $instance['simpleSettingValue'] ?? null;
if (is_array($simple)) {
return $simple['value'] ?? $simple;
$simpleValue = $simple['value'] ?? $simple;
if (is_array($simpleValue) && array_key_exists('value', $simpleValue)) {
return $simpleValue['value'];
}
return $simpleValue;
}
return $simple;
@ -530,7 +608,13 @@ private function extractSettingsCatalogValue(array $setting, ?array $instance):
$choice = $instance['choiceSettingValue'] ?? null;
if (is_array($choice)) {
return $choice['value'] ?? $choice;
$choiceValue = $choice['value'] ?? $choice;
if (is_array($choiceValue) && array_key_exists('value', $choiceValue)) {
return $choiceValue['value'];
}
return $choiceValue;
}
return $choice;
@ -748,11 +832,17 @@ private function formatSettingsCatalogValue(mixed $value): string
if (is_string($value)) {
// Remove {tenantid} placeholder
$value = str_replace(['{tenantid}', '_tenantid_'], ['', '_'], $value);
$value = preg_replace('/\{[^}]+\}/', '', $value);
$value = preg_replace('/_+/', '_', $value);
// Extract choice label from choice values (last meaningful part)
// Example: "device_vendor_msft_...lowercaseletters_0" -> "Lowercase Letters: 0"
if (str_contains($value, 'device_vendor_msft') || str_contains($value, 'user_vendor_msft') || str_contains($value, '#microsoft.graph')) {
if (
str_contains($value, 'device_vendor_msft')
|| str_contains($value, 'user_vendor_msft')
|| str_contains($value, 'vendor_msft')
|| str_contains($value, '#microsoft.graph')
) {
$parts = explode('_', $value);
$lastPart = end($parts);
@ -761,6 +851,29 @@ private function formatSettingsCatalogValue(mixed $value): string
return strtolower($lastPart) === 'true' ? 'Enabled' : 'Disabled';
}
$commonLastPartMapping = [
'in' => 'Inbound',
'out' => 'Outbound',
'allow' => 'Allow',
'block' => 'Block',
'tcp' => 'TCP',
'udp' => 'UDP',
'icmpv4' => 'ICMPv4',
'icmpv6' => 'ICMPv6',
'any' => 'Any',
'notconfigured' => 'Not configured',
'lan' => 'LAN',
'wireless' => 'Wireless',
'remoteaccess' => 'Remote access',
'domain' => 'Domain',
'private' => 'Private',
'public' => 'Public',
];
if (is_string($lastPart) && isset($commonLastPartMapping[strtolower($lastPart)])) {
return $commonLastPartMapping[strtolower($lastPart)];
}
// If last part is just a number, take second-to-last too
if (is_numeric($lastPart) && count($parts) > 1) {
$secondLast = $parts[count($parts) - 2];
@ -792,6 +905,33 @@ private function formatSettingsCatalogValue(mixed $value): string
}
if (is_array($value)) {
if ($value === []) {
return '-';
}
if (array_is_list($value)) {
$parts = [];
foreach ($value as $item) {
if ($item === null) {
continue;
}
if (! is_bool($item) && ! is_int($item) && ! is_float($item) && ! is_string($item)) {
$parts = [];
break;
}
$parts[] = $this->formatSettingsCatalogValue($item);
}
$parts = array_values(array_unique(array_filter($parts, static fn (string $part): bool => $part !== '' && $part !== '-')));
if ($parts !== []) {
return implode(', ', $parts);
}
}
return json_encode($value);
}

View File

@ -0,0 +1,143 @@
<?php
namespace App\Services\Intune;
use Illuminate\Support\Str;
class ManagedDeviceAppConfigurationNormalizer implements PolicyTypeNormalizer
{
public function __construct(
private readonly DefaultPolicyNormalizer $defaultNormalizer,
) {}
public function supports(string $policyType): bool
{
return $policyType === 'managedDeviceAppConfiguration';
}
/**
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>}
*/
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
{
$snapshot = $snapshot ?? [];
$normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform);
if ($snapshot === []) {
return $normalized;
}
$normalized['settings'] = array_values(array_filter(
$normalized['settings'],
static function (array $block): bool {
$title = strtolower((string) ($block['title'] ?? ''));
return $title !== 'settings' && $title !== 'settings delta';
}
));
$rows = $this->buildSettingsRows($snapshot['settings'] ?? null);
if ($rows !== []) {
$normalized['settings'][] = [
'type' => 'table',
'title' => 'App configuration settings',
'rows' => $rows,
];
} else {
$normalized['warnings'][] = 'No app configuration settings were returned by Graph. Intune only returns configured keys; items shown as "Not configured" in the portal are typically absent.';
$normalized['warnings'] = array_values(array_unique(array_filter($normalized['warnings'], static fn ($value) => is_string($value) && $value !== '')));
}
return $normalized;
}
/**
* @return array<string, mixed>
*/
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
$snapshot = $snapshot ?? [];
$normalized = $this->normalize($snapshot, $policyType, $platform);
return $this->defaultNormalizer->flattenNormalizedForDiff($normalized);
}
/**
* @return array<int, array<string, mixed>>
*/
private function buildSettingsRows(mixed $settings): array
{
if (! is_array($settings) || $settings === []) {
return [];
}
$rows = [];
foreach ($settings as $setting) {
if (! is_array($setting)) {
continue;
}
$key = $setting['appConfigKey'] ?? null;
$rawValue = $setting['appConfigKeyValue'] ?? null;
$type = $setting['appConfigKeyType'] ?? null;
if (! is_string($key) || $key === '') {
continue;
}
$value = $this->normalizeValue($rawValue, $type);
$rows[] = [
'path' => $key,
'label' => $key,
'value' => is_scalar($value) || $value === null ? $value : json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
'description' => is_string($type) && $type !== '' ? Str::headline($type) : null,
];
}
return $rows;
}
private function normalizeValue(mixed $value, mixed $type): mixed
{
$type = is_string($type) ? strtolower($type) : '';
if (is_bool($value)) {
return $value;
}
if (is_int($value) || is_float($value)) {
return $value;
}
if (is_string($value)) {
$trimmed = trim($value);
if ($type !== '' && str_contains($type, 'boolean')) {
if (in_array(strtolower($trimmed), ['true', 'false'], true)) {
return strtolower($trimmed) === 'true';
}
if (in_array(strtolower($trimmed), ['yes', 'no'], true)) {
return strtolower($trimmed) === 'yes';
}
if (in_array($trimmed, ['1', '0'], true)) {
return $trimmed === '1';
}
}
if ($type !== '' && (str_contains($type, 'integer') || str_contains($type, 'int'))) {
if (is_numeric($trimmed) && (string) (int) $trimmed === $trimmed) {
return (int) $trimmed;
}
}
return $trimmed;
}
return $value;
}
}

View File

@ -47,13 +47,21 @@ public function capture(
$snapshot = $this->snapshotService->fetch($tenant, $policy, $createdBy);
if (isset($snapshot['failure'])) {
throw new \RuntimeException($snapshot['failure']['reason'] ?? 'Unable to fetch policy snapshot');
return [
'failure' => $snapshot['failure'],
];
}
$payload = $snapshot['payload'];
$assignments = null;
$scopeTags = null;
$captureMetadata = [];
$captureMetadata = is_array($snapshot['metadata'] ?? null) ? $snapshot['metadata'] : [];
$snapshotWarnings = is_array($snapshot['warnings'] ?? null) ? $snapshot['warnings'] : [];
if ($snapshotWarnings !== []) {
$existingWarnings = is_array($captureMetadata['warnings'] ?? null) ? $captureMetadata['warnings'] : [];
$captureMetadata['warnings'] = array_values(array_unique(array_merge($existingWarnings, $snapshotWarnings)));
}
// 2. Fetch assignments if requested
if ($includeAssignments) {
@ -179,9 +187,9 @@ public function capture(
// 5. Create new PolicyVersion with all captured data
$metadata = array_merge(
['source' => 'orchestrated_capture'],
['capture_source' => 'orchestrated_capture'],
$metadata,
$captureMetadata
$captureMetadata,
);
$version = $this->versionService->captureVersion(

View File

@ -8,6 +8,7 @@
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphErrorMapper;
use App\Services\Graph\GraphLogger;
use App\Services\Graph\GraphResponse;
use Illuminate\Support\Arr;
use Throwable;
@ -62,6 +63,11 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
} catch (Throwable $throwable) {
$mapped = GraphErrorMapper::fromThrowable($throwable, $context);
// For certain policy types experiencing upstream Graph issues, fall back to metadata-only
if ($this->shouldFallbackToMetadata($policy->policy_type, $mapped->status)) {
return $this->createMetadataOnlySnapshot($policy, $mapped->getMessage(), $mapped->status);
}
return [
'failure' => [
'policy_id' => $policy->id,
@ -87,8 +93,9 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
);
}
if ($policy->policy_type === 'settingsCatalogPolicy') {
[$payload, $metadata] = $this->hydrateSettingsCatalog(
if (in_array($policy->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)) {
[$payload, $metadata] = $this->hydrateConfigurationPolicySettings(
policyType: $policy->policy_type,
tenantIdentifier: $tenantIdentifier,
tenant: $tenant,
policyId: $policy->external_id,
@ -118,7 +125,12 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
}
if ($response->failed()) {
$reason = $response->warnings[0] ?? 'Graph request failed';
$reason = $this->formatGraphFailureReason($response);
if ($this->shouldFallbackToMetadata($policy->policy_type, $response->status)) {
return $this->createMetadataOnlySnapshot($policy, $reason, $response->status);
}
$failure = [
'policy_id' => $policy->id,
'reason' => $reason,
@ -162,6 +174,47 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
];
}
private function formatGraphFailureReason(GraphResponse $response): string
{
$code = $response->meta['error_code']
?? ($response->errors[0]['code'] ?? null)
?? ($response->data['error']['code'] ?? null);
$message = $response->meta['error_message']
?? ($response->errors[0]['message'] ?? null)
?? ($response->data['error']['message'] ?? null)
?? ($response->warnings[0] ?? null);
$reason = 'Graph request failed';
if (is_string($message) && $message !== '') {
$reason = $message;
}
if (is_string($code) && $code !== '') {
$reason = sprintf('%s: %s', $code, $reason);
}
$requestId = $response->meta['request_id'] ?? null;
$clientRequestId = $response->meta['client_request_id'] ?? null;
$suffixParts = [];
if (is_string($clientRequestId) && $clientRequestId !== '') {
$suffixParts[] = sprintf('client_request_id=%s', $clientRequestId);
}
if (is_string($requestId) && $requestId !== '') {
$suffixParts[] = sprintf('request_id=%s', $requestId);
}
if ($suffixParts !== []) {
$reason = sprintf('%s (%s)', $reason, implode(', ', $suffixParts));
}
return $reason;
}
/**
* Hydrate Windows Update Ring payload via derived type cast to capture
* windowsUpdateForBusinessConfiguration-specific properties.
@ -263,14 +316,14 @@ private function filterMetadataOnlyPayload(string $policyType, array $payload):
}
/**
* Hydrate settings catalog policies with configuration settings subresource.
* Hydrate configurationPolicies settings via settings subresource (Settings Catalog / Endpoint Security / Baselines).
*
* @return array{0:array,1:array}
*/
private function hydrateSettingsCatalog(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
private function hydrateConfigurationPolicySettings(string $policyType, string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
{
$strategy = $this->contracts->memberHydrationStrategy('settingsCatalogPolicy');
$settingsPath = $this->contracts->subresourceSettingsPath('settingsCatalogPolicy', $policyId);
$strategy = $this->contracts->memberHydrationStrategy($policyType);
$settingsPath = $this->contracts->subresourceSettingsPath($policyType, $policyId);
if ($strategy !== 'subresource_settings' || ! $settingsPath) {
return [$payload, $metadata];
@ -592,6 +645,69 @@ private function stripGraphBaseUrl(string $nextLink): string
return ltrim(substr($nextLink, strlen($base)), '/');
}
return ltrim($nextLink, '/');
return $nextLink;
}
/**
* Determine if we should fall back to metadata-only for this policy type and error.
*/
private function shouldFallbackToMetadata(string $policyType, ?int $status): bool
{
// Only fallback on 5xx server errors
if ($status === null || $status < 500 || $status >= 600) {
return false;
}
// Enable fallback for policy types experiencing upstream Graph issues
$fallbackTypes = [
'mamAppConfiguration',
'managedDeviceAppConfiguration',
];
return in_array($policyType, $fallbackTypes, true);
}
/**
* Create a metadata-only snapshot from the Policy model when Graph is unavailable.
*
* @return array{payload:array,metadata:array,warnings:array}
*/
private function createMetadataOnlySnapshot(Policy $policy, string $failureReason, ?int $status): array
{
$odataType = match ($policy->policy_type) {
'mamAppConfiguration' => '#microsoft.graph.targetedManagedAppConfiguration',
'managedDeviceAppConfiguration' => '#microsoft.graph.managedDeviceMobileAppConfiguration',
default => '#microsoft.graph.'.$policy->policy_type,
};
$payload = [
'id' => $policy->external_id,
'displayName' => $policy->display_name,
'@odata.type' => $odataType,
'createdDateTime' => $policy->created_at?->toIso8601String(),
'lastModifiedDateTime' => $policy->updated_at?->toIso8601String(),
];
if ($policy->platform) {
$payload['platform'] = $policy->platform;
}
$metadata = [
'source' => 'metadata_only',
'original_failure' => $failureReason,
'original_status' => $status,
'warnings' => [
sprintf(
'Snapshot captured from local metadata only (Graph API returned %s). Restore preview available, full restore not possible.',
$status ?? 'error'
),
],
];
return [
'payload' => $payload,
'metadata' => $metadata,
'warnings' => $metadata['warnings'],
];
}
}

View File

@ -25,6 +25,19 @@ public function __construct(
* @return array<int> IDs of policies synced or created
*/
public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): array
{
$result = $this->syncPoliciesWithReport($tenant, $supportedTypes);
return $result['synced'];
}
/**
* Sync supported policies for a tenant from Microsoft Graph.
*
* @param array<int, array{type: string, platform?: string|null, filter?: string|null}>|null $supportedTypes
* @return array{synced: array<int>, failures: array<int, array{policy_type: string, status: int|null, errors: array, meta: array}>}
*/
public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes = null): array
{
if (! $tenant->isActive()) {
throw new \RuntimeException('Tenant is archived or inactive.');
@ -32,6 +45,7 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
$types = $supportedTypes ?? config('tenantpilot.supported_policy_types', []);
$synced = [];
$failures = [];
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
foreach ($types as $typeConfig) {
@ -69,6 +83,13 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
]);
if ($response->failed()) {
$failures[] = [
'policy_type' => $policyType,
'status' => $response->status,
'errors' => $response->errors,
'meta' => $response->meta,
];
continue;
}
@ -109,6 +130,12 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
policyType: $policyType,
);
$this->reclassifyConfigurationPoliciesIfNeeded(
tenantId: $tenant->id,
externalId: $externalId,
policyType: $policyType,
);
$policy = Policy::updateOrCreate(
[
'tenant_id' => $tenant->id,
@ -128,11 +155,18 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
}
}
return $synced;
return [
'synced' => $synced,
'failures' => $failures,
];
}
private function resolveCanonicalPolicyType(string $policyType, array $policyData): string
{
if (in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)) {
return $this->resolveConfigurationPolicyType($policyData);
}
if (! in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) {
return $policyType;
}
@ -141,11 +175,75 @@ private function resolveCanonicalPolicyType(string $policyType, array $policyDat
return 'windowsEnrollmentStatusPage';
}
if ($this->isEnrollmentRestrictionItem($policyData)) {
return 'enrollmentRestriction';
return 'enrollmentRestriction';
}
private function resolveConfigurationPolicyType(array $policyData): string
{
if ($this->isSecurityBaselineConfigurationPolicy($policyData)) {
return 'securityBaselinePolicy';
}
return $policyType;
if ($this->isEndpointSecurityConfigurationPolicy($policyData)) {
return 'endpointSecurityPolicy';
}
return 'settingsCatalogPolicy';
}
private function isEndpointSecurityConfigurationPolicy(array $policyData): bool
{
$technologies = $policyData['technologies'] ?? null;
if (is_string($technologies)) {
if (strcasecmp(trim($technologies), 'endpointSecurity') === 0) {
return true;
}
}
if (is_array($technologies)) {
foreach ($technologies as $technology) {
if (is_string($technology) && strcasecmp(trim($technology), 'endpointSecurity') === 0) {
return true;
}
}
}
$templateReference = $policyData['templateReference'] ?? null;
if (! is_array($templateReference)) {
return false;
}
foreach ($templateReference as $value) {
if (is_string($value) && stripos($value, 'endpoint') !== false) {
return true;
}
}
return false;
}
private function isSecurityBaselineConfigurationPolicy(array $policyData): bool
{
$templateReference = $policyData['templateReference'] ?? null;
if (! is_array($templateReference)) {
return false;
}
$templateFamily = $templateReference['templateFamily'] ?? null;
if (is_string($templateFamily) && stripos($templateFamily, 'baseline') !== false) {
return true;
}
foreach ($templateReference as $value) {
if (is_string($value) && stripos($value, 'baseline') !== false) {
return true;
}
}
return false;
}
private function isEnrollmentStatusPageItem(array $policyData): bool
@ -157,33 +255,6 @@ private function isEnrollmentStatusPageItem(array $policyData): bool
|| (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)) {
@ -231,6 +302,53 @@ private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId
->update(['policy_type' => $policyType]);
}
private function reclassifyConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void
{
$configurationTypes = ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'];
if (! in_array($policyType, $configurationTypes, true)) {
return;
}
$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', $configurationTypes)
->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', $configurationTypes)
->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.
*/

View File

@ -38,6 +38,7 @@ public function check(Tenant $tenant, BackupSet $backupSet, ?array $selectedItem
$results = [];
$results[] = $this->checkOrphanedGroups($tenant, $policyItems, $groupMapping);
$results[] = $this->checkMetadataOnlySnapshots($policyItems);
$results[] = $this->checkPreviewOnlyPolicies($policyItems);
$results[] = $this->checkMissingPolicies($tenant, $policyItems);
$results[] = $this->checkStalePolicies($tenant, $policyItems);
@ -228,6 +229,91 @@ private function checkPreviewOnlyPolicies(Collection $policyItems): ?array
];
}
/**
* Detect snapshots that were captured as metadata-only.
*
* These snapshots cannot be safely restored because they do not contain the
* complete settings payload.
*
* @param Collection<int, BackupItem> $policyItems
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
*/
private function checkMetadataOnlySnapshots(Collection $policyItems): ?array
{
$affected = [];
$hasRestoreEnabled = false;
foreach ($policyItems as $item) {
if (! $this->isMetadataOnlySnapshot($item)) {
continue;
}
$restoreMode = $this->resolveRestoreMode($item->policy_type);
if ($restoreMode !== 'preview-only') {
$hasRestoreEnabled = true;
}
$affected[] = [
'backup_item_id' => $item->id,
'policy_identifier' => $item->policy_identifier,
'policy_type' => $item->policy_type,
'label' => $item->resolvedDisplayName(),
'restore_mode' => $restoreMode,
];
}
if ($affected === []) {
return [
'code' => 'metadata_only',
'severity' => 'safe',
'title' => 'Snapshot completeness',
'message' => 'No metadata-only snapshots detected.',
'meta' => [
'count' => 0,
],
];
}
$severity = $hasRestoreEnabled ? 'blocking' : 'warning';
$message = $hasRestoreEnabled
? 'Some selected items were captured as metadata-only. Restore cannot execute until Graph works again.'
: 'Some selected items were captured as metadata-only. Execution is preview-only, but payload completeness is limited.';
return [
'code' => 'metadata_only',
'severity' => $severity,
'title' => 'Snapshot completeness',
'message' => $message,
'meta' => [
'count' => count($affected),
'items' => $this->truncateList($affected, 10),
],
];
}
private function isMetadataOnlySnapshot(BackupItem $item): bool
{
$metadata = is_array($item->metadata) ? $item->metadata : [];
$source = $metadata['source'] ?? null;
$snapshotSource = $metadata['snapshot_source'] ?? null;
if ($source === 'metadata_only' || $snapshotSource === 'metadata_only') {
return true;
}
$warnings = $metadata['warnings'] ?? null;
if (is_array($warnings)) {
foreach ($warnings as $warning) {
if (is_string($warning) && Str::contains(Str::lower($warning), 'metadata only')) {
return true;
}
}
}
return false;
}
/**
* @param Collection<int, BackupItem> $policyItems
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null

View File

@ -151,6 +151,18 @@ public function executeFromPolicyVersion(
'version_captured_at' => $version->captured_at?->toIso8601String(),
];
$versionMetadata = is_array($version->metadata) ? $version->metadata : [];
$snapshotSource = $versionMetadata['source'] ?? null;
if (is_string($snapshotSource) && $snapshotSource !== '' && $snapshotSource !== 'policy_version') {
$backupItemMetadata['snapshot_source'] = $snapshotSource;
}
$snapshotWarnings = $versionMetadata['warnings'] ?? null;
if (is_array($snapshotWarnings) && $snapshotWarnings !== []) {
$backupItemMetadata['warnings'] = array_values(array_unique(array_filter($snapshotWarnings, static fn ($value) => is_string($value) && $value !== '')));
}
if (is_array($scopeTagIds) && $scopeTagIds !== []) {
$backupItemMetadata['scope_tag_ids'] = $scopeTagIds;
}

View File

@ -269,10 +269,49 @@ public function prettifyDefinitionId(string $definitionId): string
// Remove {tenantid} placeholder - it's a Microsoft template variable, not part of the name
$cleaned = str_replace(['{tenantid}', '_tenantid_', '_{tenantid}_'], ['', '_', '_'], $definitionId);
// Remove other template placeholders, e.g. "{FirewallRuleId}"
$cleaned = preg_replace('/\{[^}]+\}/', '', $cleaned);
// Clean up consecutive underscores
$cleaned = preg_replace('/_+/', '_', $cleaned);
$cleaned = trim($cleaned, '_');
$lowered = Str::lower($cleaned);
if (str_starts_with($lowered, 'vendor_msft_firewall_mdmstore_firewallrules')) {
$suffix = ltrim(substr($lowered, strlen('vendor_msft_firewall_mdmstore_firewallrules')), '_');
if ($suffix === '') {
return 'Firewall rule';
}
$known = [
'displayname' => 'Name',
'name' => 'Name',
'description' => 'Description',
'direction' => 'Direction',
'action' => 'Action',
'actiontype' => 'Action type',
'profiles' => 'Profiles',
'profile' => 'Profile',
'protocol' => 'Protocol',
'localport' => 'Local port',
'remoteport' => 'Remote port',
'localaddress' => 'Local address',
'remoteaddress' => 'Remote address',
'interfacetype' => 'Interface type',
'interfacetypes' => 'Interface types',
'edgetraversal' => 'Edge traversal',
'enabled' => 'Enabled',
];
if (isset($known[$suffix])) {
return $known[$suffix];
}
return Str::headline($suffix);
}
// Convert to title case
$prettified = Str::title(str_replace('_', ' ', $cleaned));

View File

@ -10,7 +10,7 @@ public function __construct(
public function supports(string $policyType): bool
{
return $policyType === 'settingsCatalogPolicy';
return in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true);
}
/**

View File

@ -85,6 +85,8 @@ public function captureFromGraph(
}
$payload = $snapshot['payload'];
$snapshotMetadata = is_array($snapshot['metadata'] ?? null) ? $snapshot['metadata'] : [];
$snapshotWarnings = is_array($snapshot['warnings'] ?? null) ? $snapshot['warnings'] : [];
$assignments = null;
$scopeTags = null;
$assignmentMetadata = [];
@ -141,11 +143,17 @@ public function captureFromGraph(
}
$metadata = array_merge(
['source' => 'version_capture'],
$snapshotMetadata,
['capture_source' => 'version_capture'],
$metadata,
$assignmentMetadata
$assignmentMetadata,
);
if ($snapshotWarnings !== []) {
$existingWarnings = is_array($metadata['warnings'] ?? null) ? $metadata['warnings'] : [];
$metadata['warnings'] = array_values(array_unique(array_merge($existingWarnings, $snapshotWarnings)));
}
return $this->captureVersion(
policy: $policy,
payload: $payload,

View File

@ -78,7 +78,7 @@
],
'settingsCatalogPolicy' => [
'resource' => 'deviceManagement/configurationPolicies',
'allowed_select' => ['id', 'name', 'displayName', 'description', '@odata.type', 'version', 'platforms', 'technologies', 'roleScopeTagIds', 'lastModifiedDateTime'],
'allowed_select' => ['id', 'name', 'description', '@odata.type', 'platforms', 'technologies', 'templateReference', 'roleScopeTagIds', 'lastModifiedDateTime'],
'allowed_expand' => ['settings'],
'type_family' => [
'#microsoft.graph.deviceManagementConfigurationPolicy',
@ -132,6 +132,76 @@
'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds',
],
'endpointSecurityPolicy' => [
'resource' => 'deviceManagement/configurationPolicies',
'allowed_select' => ['id', 'name', 'description', '@odata.type', 'platforms', 'technologies', 'roleScopeTagIds', 'lastModifiedDateTime', 'templateReference'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.deviceManagementConfigurationPolicy',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'member_hydration_strategy' => 'subresource_settings',
'subresources' => [
'settings' => [
'path' => 'deviceManagement/configurationPolicies/{id}/settings',
'collection' => true,
'paging' => true,
'allowed_select' => [],
'allowed_expand' => [],
],
],
// Assignments CRUD (standard Graph pattern)
'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments',
'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
// Scope Tags
'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds',
],
'securityBaselinePolicy' => [
'resource' => 'deviceManagement/configurationPolicies',
'allowed_select' => ['id', 'name', 'description', '@odata.type', 'platforms', 'technologies', 'roleScopeTagIds', 'lastModifiedDateTime', 'templateReference'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.deviceManagementConfigurationPolicy',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'member_hydration_strategy' => 'subresource_settings',
'subresources' => [
'settings' => [
'path' => 'deviceManagement/configurationPolicies/{id}/settings',
'collection' => true,
'paging' => true,
'allowed_select' => [],
'allowed_expand' => [],
],
],
// Assignments CRUD (standard Graph pattern)
'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments',
'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
// Scope Tags
'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds',
],
'windowsUpdateRing' => [
'resource' => 'deviceManagement/deviceConfigurations',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'],
@ -263,6 +333,43 @@
'assignments_create_method' => 'POST',
'assignments_payload_key' => 'assignments',
],
'mamAppConfiguration' => [
'resource' => 'deviceAppManagement/targetedManagedAppConfigurations',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'createdDateTime', 'lastModifiedDateTime', 'roleScopeTagIds'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.targetedManagedAppConfiguration',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'assignments_list_path' => '/deviceAppManagement/targetedManagedAppConfigurations/{id}/assignments',
'assignments_create_path' => '/deviceAppManagement/targetedManagedAppConfigurations/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_payload_key' => 'assignments',
'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds',
],
'managedDeviceAppConfiguration' => [
'resource' => 'deviceAppManagement/mobileAppConfigurations',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime', 'roleScopeTagIds'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.managedDeviceMobileAppConfiguration',
'#microsoft.graph.mobileAppConfiguration',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'assignments_list_path' => '/deviceAppManagement/mobileAppConfigurations/{id}/assignments',
'assignments_create_path' => '/deviceAppManagement/mobileAppConfigurations/{id}/microsoft.graph.managedDeviceMobileAppConfiguration/assign',
'assignments_create_method' => 'POST',
'assignments_payload_key' => 'assignments',
'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds',
],
'conditionalAccessPolicy' => [
'resource' => 'identity/conditionalAccess/policies',
'allowed_select' => ['id', 'displayName', 'state', 'createdDateTime', 'modifiedDateTime', '@odata.type'],

View File

@ -84,6 +84,27 @@
'restore' => 'enabled',
'risk' => 'medium-high',
],
[
'type' => 'mamAppConfiguration',
'label' => 'App Configuration (MAM)',
'category' => 'Apps/MAM',
'platform' => 'mobile',
'endpoint' => 'deviceAppManagement/targetedManagedAppConfigurations',
'backup' => 'full',
'restore' => 'enabled',
'risk' => 'medium-high',
],
[
'type' => 'managedDeviceAppConfiguration',
'label' => 'App Configuration (Device)',
'category' => 'Apps/MAM',
'platform' => 'mobile',
'endpoint' => 'deviceAppManagement/mobileAppConfigurations',
'filter' => "microsoft.graph.androidManagedStoreAppConfiguration/appSupportsOemConfig eq false or isof('microsoft.graph.androidManagedStoreAppConfiguration') eq false",
'backup' => 'full',
'restore' => 'enabled',
'risk' => 'medium-high',
],
[
'type' => 'conditionalAccessPolicy',
'label' => 'Conditional Access',
@ -140,7 +161,6 @@
'category' => 'Enrollment',
'platform' => 'all',
'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations',
'filter' => "isof('microsoft.graph.windows10EnrollmentCompletionPageConfiguration')",
'backup' => 'full',
'restore' => 'enabled',
'risk' => 'medium',
@ -165,6 +185,26 @@
'restore' => 'enabled',
'risk' => 'high',
],
[
'type' => 'endpointSecurityPolicy',
'label' => 'Endpoint Security Policies',
'category' => 'Endpoint Security',
'platform' => 'windows',
'endpoint' => 'deviceManagement/configurationPolicies',
'backup' => 'full',
'restore' => 'preview-only',
'risk' => 'high',
],
[
'type' => 'securityBaselinePolicy',
'label' => 'Security Baselines',
'category' => 'Endpoint Security',
'platform' => 'windows',
'endpoint' => 'deviceManagement/configurationPolicies',
'backup' => 'full',
'restore' => 'preview-only',
'risk' => 'high',
],
[
'type' => 'mobileApp',
'label' => 'Applications (Metadata only)',

View File

@ -1,4 +1,7 @@
@php
use Carbon\CarbonImmutable;
use Illuminate\Support\Str;
$general = $getState();
$entries = is_array($general) ? ($general['entries'] ?? []) : [];
$cards = [];
@ -61,6 +64,27 @@
'teal' => 'bg-teal-100/80 text-teal-700 dark:bg-teal-900/40 dark:text-teal-200',
'slate' => 'bg-slate-100/80 text-slate-700 dark:bg-slate-900/40 dark:text-slate-200',
];
$formatIsoDateTime = static function (string $value): ?string {
$trimmed = trim($value);
if ($trimmed === '') {
return null;
}
if (! preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/', $trimmed)) {
return null;
}
// Graph can return 7 fractional digits; PHP supports 6 (microseconds).
$normalized = preg_replace('/\.(\d{6})\d+Z$/', '.$1Z', $trimmed);
try {
return CarbonImmutable::parse($normalized)->toDateTimeString();
} catch (\Throwable) {
return null;
}
};
@endphp
@if (empty($cards))
@ -72,6 +96,9 @@
$keyLower = $entry['key_lower'] ?? '';
$value = $entry['value'] ?? null;
$isPlatform = str_contains($keyLower, 'platform');
$isTechnologies = str_contains($keyLower, 'technolog');
$isTemplateReference = str_contains($keyLower, 'template');
$isDateTime = is_string($value) && ($formattedDateTime = $formatIsoDateTime($value)) !== null;
$toneKey = match (true) {
str_contains($keyLower, 'name') => 'name',
str_contains($keyLower, 'platform') => 'platform',
@ -88,6 +115,15 @@
$isBooleanValue = is_bool($value);
$isBooleanString = is_string($value) && in_array(strtolower($value), ['true', 'false', 'enabled', 'disabled'], true);
$isNumericValue = is_numeric($value);
$badgeItems = null;
if ($isListValue) {
$badgeItems = $value;
} elseif (($isPlatform || $isTechnologies) && is_string($value)) {
$split = array_values(array_filter(array_map('trim', explode(',', $value)), static fn (string $item): bool => $item !== ''));
$badgeItems = $split !== [] ? $split : [$value];
}
@endphp
<div class="tp-policy-general-card group relative overflow-hidden rounded-xl border border-gray-200/70 bg-white p-4 shadow-sm transition duration-200 hover:-translate-y-0.5 hover:border-gray-300/70 hover:shadow-md dark:border-gray-700/60 dark:bg-gray-900 dark:hover:border-gray-600">
@ -100,16 +136,50 @@
{{ $entry['key'] ?? '-' }}
</dt>
<dd class="mt-2 text-left">
@if ($isListValue)
@if ($isTemplateReference && is_array($value))
@php
$templateDisplayName = $value['templateDisplayName'] ?? null;
$templateFamily = $value['templateFamily'] ?? null;
$templateDisplayVersion = $value['templateDisplayVersion'] ?? null;
$templateId = $value['templateId'] ?? null;
$familyLabel = is_string($templateFamily) && $templateFamily !== '' ? Str::headline($templateFamily) : null;
@endphp
<div class="space-y-2">
<div class="text-sm font-semibold text-gray-900 dark:text-white">
{{ is_string($templateDisplayName) && $templateDisplayName !== '' ? $templateDisplayName : 'Template' }}
</div>
<div class="flex flex-wrap gap-2">
@if ($familyLabel)
<x-filament::badge color="gray" size="sm">{{ $familyLabel }}</x-filament::badge>
@endif
@if (is_string($templateDisplayVersion) && $templateDisplayVersion !== '')
<x-filament::badge color="gray" size="sm">{{ $templateDisplayVersion }}</x-filament::badge>
@endif
</div>
@if (is_string($templateId) && $templateId !== '')
<div class="text-xs font-mono text-gray-500 dark:text-gray-400 break-all">
{{ $templateId }}
</div>
@endif
</div>
@elseif ($isDateTime)
<div class="text-sm font-semibold text-gray-900 dark:text-white tabular-nums">
{{ $formattedDateTime }}
</div>
@elseif (is_array($badgeItems) && $badgeItems !== [])
<div class="flex flex-wrap gap-2">
@foreach ($value as $item)
@foreach ($badgeItems as $item)
<x-filament::badge :color="$isPlatform ? 'info' : 'gray'" size="sm">
{{ $item }}
</x-filament::badge>
@endforeach
</div>
@elseif ($isJsonValue)
<pre class="whitespace-pre-wrap rounded-lg border border-gray-200 bg-gray-50 p-2 text-xs font-mono text-gray-700 dark:border-gray-700 dark:bg-gray-900/60 dark:text-gray-200">{{ json_encode($value, JSON_PRETTY_PRINT) }}</pre>
<pre class="whitespace-pre-wrap rounded-lg border border-gray-200 bg-gray-50 p-2 text-xs font-mono text-gray-700 dark:border-gray-700 dark:bg-gray-900/60 dark:text-gray-200">{{ json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) }}</pre>
@elseif ($isBooleanValue || $isBooleanString)
@php
$boolValue = $isBooleanValue
@ -126,7 +196,7 @@
</div>
@else
<div class="text-sm text-gray-900 dark:text-white whitespace-pre-wrap break-words text-left">
{{ is_string($value) ? $value : json_encode($value, JSON_PRETTY_PRINT) }}
{{ is_string($value) ? $value : json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) }}
</div>
@endif
</dd>

View File

@ -59,6 +59,24 @@
return true;
};
$asEnabledDisabledBadgeValue = function (mixed $value): ?bool {
if (is_bool($value)) {
return $value;
}
if (! is_string($value)) {
return null;
}
$normalized = strtolower(trim($value));
return match ($normalized) {
'enabled', 'true', 'yes', '1' => true,
'disabled', 'false', 'no', '0' => false,
default => null,
};
};
@endphp
<div class="space-y-4">
@ -98,9 +116,13 @@
</dt>
<dd class="mt-1 sm:mt-0 sm:col-span-2">
<span class="text-sm text-gray-900 dark:text-white">
@if(is_bool($row['value']))
<x-filament::badge :color="$row['value'] ? 'success' : 'gray'" size="sm">
{{ $row['value'] ? 'Enabled' : 'Disabled' }}
@php
$badgeValue = $asEnabledDisabledBadgeValue($row['value'] ?? null);
@endphp
@if(! is_null($badgeValue))
<x-filament::badge :color="$badgeValue ? 'success' : 'gray'" size="sm">
{{ $badgeValue ? 'Enabled' : 'Disabled' }}
</x-filament::badge>
@elseif(is_numeric($row['value']))
<span class="font-mono font-semibold">{{ $row['value'] }}</span>
@ -135,16 +157,20 @@
<div class="divide-y divide-gray-200 dark:divide-gray-700">
@foreach($block['rows'] ?? [] as $row)
<div class="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 break-words">
{{ $row['label'] ?? $row['path'] ?? 'Setting' }}
@if(!empty($row['description']))
<p class="text-xs text-gray-400 mt-0.5">{{ Str::limit($row['description'], 80) }}</p>
@endif
</dt>
<dd class="mt-1 sm:mt-0 sm:col-span-2">
@if(is_bool($row['value']))
<x-filament::badge :color="$row['value'] ? 'success' : 'gray'" size="sm">
{{ $row['value'] ? 'Enabled' : 'Disabled' }}
@php
$badgeValue = $asEnabledDisabledBadgeValue($row['value'] ?? null);
@endphp
@if(! is_null($badgeValue))
<x-filament::badge :color="$badgeValue ? 'success' : 'gray'" size="sm">
{{ $badgeValue ? 'Enabled' : 'Disabled' }}
</x-filament::badge>
@elseif(is_numeric($row['value']))
<span class="font-mono text-sm font-semibold text-gray-900 dark:text-white">
@ -192,6 +218,8 @@
$isScriptContent = in_array($entry['key'] ?? null, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true)
&& (bool) config('tenantpilot.display.show_script_content', false);
$badgeValue = $asEnabledDisabledBadgeValue($rawValue);
@endphp
@if($isScriptContent)
@ -276,6 +304,10 @@
</x-filament::badge>
@endforeach
</div>
@elseif(! is_null($badgeValue))
<x-filament::badge :color="$badgeValue ? 'success' : 'gray'" size="sm">
{{ $badgeValue ? 'Enabled' : 'Disabled' }}
</x-filament::badge>
@else
<span class="text-sm text-gray-900 dark:text-white break-words">
{{ Str::limit($stringifyValue($rawValue), 200) }}

View File

@ -53,6 +53,105 @@
])
->callTableBulkAction('add_selected_to_backup_set', $policies)
->assertHasNoTableBulkActionErrors();
$notifications = session('filament.notifications', []);
expect($notifications)->not->toBeEmpty();
expect(collect($notifications)->last()['title'] ?? null)->toBe('Backup items added');
expect(collect($notifications)->last()['status'] ?? null)->toBe('success');
});
test('policy picker table does not warn if failures already existed but did not increase', function () {
$tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$user = User::factory()->create();
$backupSet = BackupSet::factory()->create([
'tenant_id' => $tenant->id,
'name' => 'Test backup',
'status' => 'partial',
'metadata' => [
'failures' => [
['policy_id' => 1, 'reason' => 'Previous failure', 'status' => 500],
],
],
]);
$policies = Policy::factory()->count(1)->create([
'tenant_id' => $tenant->id,
'ignored_at' => null,
'last_synced_at' => now(),
]);
$this->mock(BackupService::class, function (MockInterface $mock) use ($backupSet) {
$mock->shouldReceive('addPoliciesToSet')
->once()
->andReturn($backupSet);
});
Livewire::actingAs($user)
->test(BackupSetPolicyPickerTable::class, [
'backupSetId' => $backupSet->id,
])
->callTableBulkAction('add_selected_to_backup_set', $policies)
->assertHasNoTableBulkActionErrors();
$notifications = session('filament.notifications', []);
expect($notifications)->not->toBeEmpty();
expect(collect($notifications)->last()['title'] ?? null)->toBe('Backup items added');
expect(collect($notifications)->last()['status'] ?? null)->toBe('success');
});
test('policy picker table warns when new failures were added', function () {
$tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$user = User::factory()->create();
$backupSet = BackupSet::factory()->create([
'tenant_id' => $tenant->id,
'name' => 'Test backup',
'status' => 'completed',
'metadata' => ['failures' => []],
]);
$policies = Policy::factory()->count(1)->create([
'tenant_id' => $tenant->id,
'ignored_at' => null,
'last_synced_at' => now(),
]);
$this->mock(BackupService::class, function (MockInterface $mock) use ($backupSet) {
$mock->shouldReceive('addPoliciesToSet')
->once()
->andReturnUsing(function () use ($backupSet) {
$backupSet->update([
'status' => 'partial',
'metadata' => [
'failures' => [
['policy_id' => 123, 'reason' => 'New failure', 'status' => 500],
],
],
]);
return $backupSet->refresh();
});
});
Livewire::actingAs($user)
->test(BackupSetPolicyPickerTable::class, [
'backupSetId' => $backupSet->id,
])
->callTableBulkAction('add_selected_to_backup_set', $policies)
->assertHasNoTableBulkActionErrors();
$notifications = session('filament.notifications', []);
expect($notifications)->not->toBeEmpty();
expect(collect($notifications)->last()['title'] ?? null)->toBe('Backup items added with failures');
expect(collect($notifications)->last()['status'] ?? null)->toBe('warning');
});
test('policy picker table can filter by has versions', function () {

View File

@ -11,7 +11,7 @@
uses(RefreshDatabase::class);
test('settings catalog policies render a normalized settings table', function () {
test('configuration policy types render a normalized settings table', function (string $policyType) {
$tenant = Tenant::create([
'tenant_id' => 'local-tenant',
'name' => 'Tenant One',
@ -24,7 +24,7 @@
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'scp-policy-1',
'policy_type' => 'settingsCatalogPolicy',
'policy_type' => $policyType,
'display_name' => 'Settings Catalog Policy',
'platform' => 'windows',
]);
@ -33,7 +33,7 @@
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_number' => 1,
'policy_type' => $policy->policy_type,
'policy_type' => $policyType,
'platform' => $policy->platform,
'created_by' => 'tester@example.com',
'captured_at' => CarbonImmutable::now(),
@ -116,4 +116,8 @@
preg_match('/<section[^>]*data-block="general"[^>]*>.*?<\/section>/is', $versionResponse->getContent(), $versionGeneralSection);
expect($versionGeneralSection)->not->toBeEmpty();
expect($versionGeneralSection[0])->toContain('x-cloak');
});
})->with([
'settingsCatalogPolicy',
'endpointSecurityPolicy',
'securityBaselinePolicy',
]);

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\View;
it('renders template reference, badges, and readable timestamps in policy general cards', function () {
$html = View::file(
resource_path('views/filament/infolists/entries/policy-general.blade.php'),
[
'getState' => fn (): array => [
'entries' => [
['key' => 'Name', 'value' => 'WindowsFirewall Endpointsecurity'],
['key' => 'Platforms', 'value' => 'windows10'],
['key' => 'Technologies', 'value' => 'mdm,microsoftSense'],
['key' => 'Template Reference', 'value' => [
'templateId' => '19c8aa67-f286-4861-9aa0-f23541d31680_1',
'templateFamily' => 'endpointSecurityFirewall',
'templateDisplayName' => 'Windows Firewall Rules',
'templateDisplayVersion' => 'Version 1',
]],
['key' => 'Last Modified', 'value' => '2026-01-03T00:52:32.2784312Z'],
],
],
],
)->render();
expect($html)->toContain('Windows Firewall Rules');
expect($html)->toContain('Endpoint Security Firewall');
expect($html)->toContain('Version 1');
expect($html)->toContain('19c8aa67-f286-4861-9aa0-f23541d31680_1');
expect($html)->toContain('mdm');
expect($html)->toContain('microsoftSense');
expect($html)->toContain('fi-badge');
expect($html)->toContain('2026-01-03 00:52:32');
expect($html)->not->toContain('T00:52:32.2784312Z');
expect($html)->not->toContain('"templateId"');
});

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\View;
it('renders Enabled/Disabled strings as badges', function () {
$html = View::file(
resource_path('views/filament/infolists/entries/policy-settings-standard.blade.php'),
[
'getState' => fn (): array => [
'settings' => [
[
'type' => 'table',
'title' => 'App configuration settings',
'rows' => [
['label' => 'StringEnabled', 'value' => 'Enabled'],
['label' => 'StringDisabled', 'value' => 'Disabled'],
],
],
],
'policy_type' => 'managedDeviceAppConfiguration',
],
],
)->render();
expect($html)->toContain('Enabled')
->and($html)->toContain('Disabled')
->and($html)->toContain('fi-badge');
});

View File

@ -71,3 +71,77 @@
expect($wrong->policy_type)->toBe('windowsEnrollmentStatusPage');
});
test('policy sync classifies ESP items without relying on Graph isof filter', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-sync-esp-no-filter',
'name' => 'Tenant Sync ESP No Filter',
'metadata' => [],
'is_current' => true,
]);
$tenant->makeCurrent();
$this->mock(GraphClientInterface::class, function (MockInterface $mock) {
$payload = [
[
'id' => 'esp-1',
'displayName' => 'Enrollment Status Page',
'@odata.type' => '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration',
'deviceEnrollmentConfigurationType' => 'windows10EnrollmentCompletionPageConfiguration',
],
[
'id' => 'restriction-1',
'displayName' => 'Default Enrollment Restriction',
'@odata.type' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration',
'deviceEnrollmentConfigurationType' => 'deviceEnrollmentPlatformRestrictionConfiguration',
],
[
'id' => 'other-1',
'displayName' => 'Other Enrollment Config',
'@odata.type' => '#microsoft.graph.someOtherEnrollmentConfiguration',
'deviceEnrollmentConfigurationType' => 'someOtherEnrollmentConfiguration',
],
];
$mock->shouldReceive('listPolicies')
->andReturnUsing(function (string $policyType) use ($payload) {
if (in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) {
return new GraphResponse(true, $payload);
}
return new GraphResponse(true, []);
});
});
$service = app(PolicySyncService::class);
$service->syncPolicies($tenant, [
[
'type' => 'windowsEnrollmentStatusPage',
'platform' => 'all',
'filter' => null,
],
[
'type' => 'enrollmentRestriction',
'platform' => 'all',
'filter' => null,
],
]);
$espIds = Policy::query()
->where('tenant_id', $tenant->id)
->where('policy_type', 'windowsEnrollmentStatusPage')
->pluck('external_id')
->all();
$restrictionIds = Policy::query()
->where('tenant_id', $tenant->id)
->where('policy_type', 'enrollmentRestriction')
->orderBy('external_id')
->pluck('external_id')
->all();
expect($espIds)->toMatchArray(['esp-1']);
expect($restrictionIds)->toMatchArray(['other-1', 'restriction-1']);
});

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
use App\Models\Policy;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphLogger;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\PolicySyncService;
use function Pest\Laravel\mock;
it('returns a report with failures when policy list calls fail', function () {
$tenant = Tenant::factory()->create([
'status' => 'active',
]);
$logger = mock(GraphLogger::class);
$logger->shouldReceive('logRequest')->zeroOrMoreTimes()->andReturnNull();
$logger->shouldReceive('logResponse')->zeroOrMoreTimes()->andReturnNull();
mock(GraphClientInterface::class)
->shouldReceive('listPolicies')
->andReturnUsing(function (string $policyType) {
return match ($policyType) {
'endpointSecurityPolicy' => new GraphResponse(
success: false,
data: [],
status: 403,
errors: [['message' => 'Forbidden']],
meta: ['path' => '/deviceManagement/configurationPolicies'],
),
default => new GraphResponse(
success: true,
data: [
['id' => 'scp-1', 'displayName' => 'Settings Catalog', 'technologies' => ['mdm']],
],
status: 200,
),
};
});
$service = app(PolicySyncService::class);
$result = $service->syncPoliciesWithReport($tenant, [
['type' => 'endpointSecurityPolicy', 'platform' => 'windows'],
['type' => 'settingsCatalogPolicy', 'platform' => 'windows'],
]);
expect($result)->toHaveKeys(['synced', 'failures']);
expect($result['synced'])->toBeArray();
expect($result['failures'])->toBeArray();
expect(count($result['synced']))->toBe(1);
expect(Policy::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
expect(count($result['failures']))->toBe(1);
expect($result['failures'][0]['policy_type'])->toBe('endpointSecurityPolicy');
expect($result['failures'][0]['status'])->toBe(403);
});

View File

@ -1,6 +1,7 @@
<?php
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphLogger;
@ -75,3 +76,221 @@
expect($byType['windowsQualityUpdateProfile']['endpoint'] ?? null)
->toBe('deviceManagement/windowsQualityUpdateProfiles');
});
it('includes managed device app configurations in supported types', function () {
$supported = config('tenantpilot.supported_policy_types');
$byType = collect($supported)->keyBy('type');
expect($byType)->toHaveKey('managedDeviceAppConfiguration');
expect($byType['managedDeviceAppConfiguration']['endpoint'] ?? null)
->toBe('deviceAppManagement/mobileAppConfigurations');
expect($byType['managedDeviceAppConfiguration']['filter'] ?? null)
->toBe("microsoft.graph.androidManagedStoreAppConfiguration/appSupportsOemConfig eq false or isof('microsoft.graph.androidManagedStoreAppConfiguration') eq false");
});
it('syncs managed device app configurations from Graph', function () {
$tenant = Tenant::factory()->create([
'status' => 'active',
]);
$logger = mock(GraphLogger::class);
$logger->shouldReceive('logRequest')
->zeroOrMoreTimes()
->andReturnNull();
$logger->shouldReceive('logResponse')
->zeroOrMoreTimes()
->andReturnNull();
mock(GraphClientInterface::class)
->shouldReceive('listPolicies')
->once()
->with('managedDeviceAppConfiguration', mockery::type('array'))
->andReturn(new GraphResponse(
success: true,
data: [
[
'id' => 'madc-1',
'displayName' => 'MAM Device Config',
'@odata.type' => '#microsoft.graph.managedDeviceMobileAppConfiguration',
],
],
));
$service = app(PolicySyncService::class);
$service->syncPolicies($tenant, [
['type' => 'managedDeviceAppConfiguration', 'platform' => 'mobile'],
]);
expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'managedDeviceAppConfiguration')->count())
->toBe(1);
});
it('classifies configuration policies into settings catalog, endpoint security, and security baseline types', function () {
$tenant = Tenant::factory()->create([
'status' => 'active',
]);
$logger = mock(GraphLogger::class);
$logger->shouldReceive('logRequest')
->zeroOrMoreTimes()
->andReturnNull();
$logger->shouldReceive('logResponse')
->zeroOrMoreTimes()
->andReturnNull();
$graphResponse = new GraphResponse(
success: true,
data: [
[
'id' => 'scp-1',
'name' => 'Settings Catalog Alpha',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'technologies' => ['mdm'],
'templateReference' => null,
],
[
'id' => 'esp-1',
'name' => 'Endpoint Security Beta',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'technologies' => 'mdm',
'templateReference' => [
'templateFamily' => 'endpointSecurityDiskEncryption',
'templateDisplayName' => 'BitLocker',
],
],
[
'id' => 'sb-1',
'name' => 'Security Baseline Gamma',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'technologies' => ['mdm'],
'templateReference' => [
'templateFamily' => 'securityBaseline',
],
],
],
);
$calledTypes = [];
mock(GraphClientInterface::class)
->shouldReceive('listPolicies')
->times(3)
->andReturnUsing(function (string $policyType) use (&$calledTypes, $graphResponse) {
$calledTypes[] = $policyType;
return $graphResponse;
});
$service = app(PolicySyncService::class);
$service->syncPolicies($tenant, [
['type' => 'settingsCatalogPolicy', 'platform' => 'windows'],
['type' => 'endpointSecurityPolicy', 'platform' => 'windows'],
['type' => 'securityBaselinePolicy', 'platform' => 'windows'],
]);
expect($calledTypes)->toMatchArray([
'settingsCatalogPolicy',
'endpointSecurityPolicy',
'securityBaselinePolicy',
]);
expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'settingsCatalogPolicy')->count())
->toBe(1);
expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'endpointSecurityPolicy')->count())
->toBe(1);
expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'securityBaselinePolicy')->count())
->toBe(1);
});
it('reclassifies configuration policies when canonical type changes', function () {
$tenant = Tenant::factory()->create([
'status' => 'active',
]);
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'esp-1',
'policy_type' => 'settingsCatalogPolicy',
'platform' => 'windows',
'display_name' => 'Misclassified',
'ignored_at' => null,
]);
$version = PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'policy_type' => 'settingsCatalogPolicy',
'platform' => 'windows',
]);
$logger = mock(GraphLogger::class);
$logger->shouldReceive('logRequest')
->zeroOrMoreTimes()
->andReturnNull();
$logger->shouldReceive('logResponse')
->zeroOrMoreTimes()
->andReturnNull();
$graphResponse = new GraphResponse(
success: true,
data: [
[
'id' => 'esp-1',
'name' => 'Endpoint Security Beta',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'technologies' => 'mdm',
'templateReference' => [
'templateFamily' => 'endpointSecurityDiskEncryption',
'templateDisplayName' => 'BitLocker',
],
],
],
);
mock(GraphClientInterface::class)
->shouldReceive('listPolicies')
->times(3)
->andReturn($graphResponse);
$service = app(PolicySyncService::class);
$service->syncPolicies($tenant, [
['type' => 'settingsCatalogPolicy', 'platform' => 'windows'],
['type' => 'endpointSecurityPolicy', 'platform' => 'windows'],
['type' => 'securityBaselinePolicy', 'platform' => 'windows'],
]);
expect(Policy::query()
->where('tenant_id', $tenant->id)
->where('external_id', 'esp-1')
->whereNull('ignored_at')
->count())->toBe(1);
expect(Policy::query()
->where('tenant_id', $tenant->id)
->where('external_id', 'esp-1')
->where('policy_type', 'endpointSecurityPolicy')
->whereNull('ignored_at')
->count())->toBe(1);
expect(Policy::query()
->where('tenant_id', $tenant->id)
->where('external_id', 'esp-1')
->where('policy_type', 'settingsCatalogPolicy')
->whereNull('ignored_at')
->count())->toBe(0);
$version->refresh();
expect($version->policy_type)->toBe('endpointSecurityPolicy');
});

View File

@ -0,0 +1,267 @@
<?php
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\BackupService;
use App\Services\Intune\PolicyCaptureOrchestrator;
use App\Services\Intune\RestoreService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery\MockInterface;
uses(RefreshDatabase::class);
class PolicyTypes017GraphClient implements GraphClientInterface
{
/** @var array<int, array{method:string,policyType?:string,policyId?:string,path?:string,options:array<string,mixed>}> */
public array $requests = [];
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
$this->requests[] = ['method' => 'listPolicies', 'policyType' => $policyType, 'options' => $options];
return new GraphResponse(success: true, data: []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
$this->requests[] = ['method' => 'getPolicy', 'policyType' => $policyType, 'policyId' => $policyId, 'options' => $options];
$payload = match ($policyType) {
'mamAppConfiguration' => [
'id' => $policyId,
'displayName' => 'MAM App Config',
'@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration',
'roleScopeTagIds' => ['0'],
],
'endpointSecurityPolicy' => [
'id' => $policyId,
'name' => 'Endpoint Security Policy',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'technologies' => ['endpointSecurity'],
'roleScopeTagIds' => ['0'],
],
'securityBaselinePolicy' => [
'id' => $policyId,
'name' => 'Security Baseline Policy',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'templateReference' => ['templateFamily' => 'securityBaseline'],
'roleScopeTagIds' => ['0'],
],
default => [
'id' => $policyId,
'name' => 'Settings Catalog Policy',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'technologies' => ['mdm'],
'roleScopeTagIds' => ['0'],
],
};
return new GraphResponse(success: true, data: ['payload' => $payload]);
}
public function getOrganization(array $options = []): GraphResponse
{
$this->requests[] = ['method' => 'getOrganization', 'options' => $options];
return new GraphResponse(success: true, data: []);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
$this->requests[] = ['method' => 'applyPolicy', 'policyType' => $policyType, 'policyId' => $policyId, 'options' => $options];
return new GraphResponse(success: true, data: []);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
$this->requests[] = ['method' => 'getServicePrincipalPermissions', 'options' => $options];
return new GraphResponse(success: true, data: []);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
$this->requests[] = ['method' => 'request', 'path' => $path, 'options' => $options];
return new GraphResponse(success: true, data: []);
}
}
it('creates backup items for the new 017 policy types', function () {
$tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$user = User::factory()->create();
$this->actingAs($user);
$mam = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'mam-1',
'policy_type' => 'mamAppConfiguration',
'platform' => 'mobile',
]);
$esp = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'esp-1',
'policy_type' => 'endpointSecurityPolicy',
'platform' => 'windows',
]);
$sb = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'sb-1',
'policy_type' => 'securityBaselinePolicy',
'platform' => 'windows',
]);
$this->mock(PolicyCaptureOrchestrator::class, function (MockInterface $mock) use ($tenant) {
$mock->shouldReceive('capture')
->times(3)
->andReturnUsing(function (Policy $policy) use ($tenant) {
$snapshot = match ($policy->policy_type) {
'mamAppConfiguration' => [
'id' => $policy->external_id,
'displayName' => 'MAM App Config',
'@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration',
'roleScopeTagIds' => ['0'],
],
'endpointSecurityPolicy' => [
'id' => $policy->external_id,
'name' => 'Endpoint Security Policy',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'technologies' => ['endpointSecurity'],
'roleScopeTagIds' => ['0'],
],
'securityBaselinePolicy' => [
'id' => $policy->external_id,
'name' => 'Security Baseline Policy',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'templateReference' => ['templateFamily' => 'securityBaseline'],
'roleScopeTagIds' => ['0'],
],
default => [
'id' => $policy->external_id,
'name' => 'Settings Catalog Policy',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'technologies' => ['mdm'],
'roleScopeTagIds' => ['0'],
],
};
$version = PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'snapshot' => $snapshot,
'assignments' => null,
'scope_tags' => null,
]);
return [
'version' => $version,
'captured' => [
'payload' => $snapshot,
'assignments' => null,
'scope_tags' => null,
'metadata' => [],
'warnings' => [],
],
];
});
});
$service = app(BackupService::class);
$backupSet = $service->createBackupSet(
tenant: $tenant,
policyIds: [$mam->id, $esp->id, $sb->id],
actorEmail: $user->email,
actorName: $user->name,
name: '017 backup',
includeAssignments: false,
includeScopeTags: false,
includeFoundations: false,
);
expect($backupSet->items)->toHaveCount(3);
$types = $backupSet->items->pluck('policy_type')->all();
sort($types);
expect($types)->toBe([
'endpointSecurityPolicy',
'mamAppConfiguration',
'securityBaselinePolicy',
]);
expect(BackupItem::query()->where('backup_set_id', $backupSet->id)->count())
->toBe(3);
});
it('uses configured restore modes in preview for the new 017 policy types', function () {
$this->mock(GraphClientInterface::class);
$tenant = Tenant::factory()->create();
$backupSet = BackupSet::factory()->create([
'tenant_id' => $tenant->id,
'status' => 'completed',
'item_count' => 3,
]);
BackupItem::factory()->create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => null,
'policy_identifier' => 'mam-1',
'policy_type' => 'mamAppConfiguration',
'platform' => 'mobile',
'payload' => [
'id' => 'mam-1',
'@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration',
],
]);
BackupItem::factory()->create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => null,
'policy_identifier' => 'esp-1',
'policy_type' => 'endpointSecurityPolicy',
'platform' => 'windows',
'payload' => [
'id' => 'esp-1',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'technologies' => ['endpointSecurity'],
],
]);
BackupItem::factory()->create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => null,
'policy_identifier' => 'sb-1',
'policy_type' => 'securityBaselinePolicy',
'platform' => 'windows',
'payload' => [
'id' => 'sb-1',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'templateReference' => ['templateFamily' => 'securityBaseline'],
],
]);
$service = app(RestoreService::class);
$preview = $service->preview($tenant, $backupSet);
$byType = collect($preview)->keyBy('policy_type');
expect($byType['mamAppConfiguration']['restore_mode'])->toBe('enabled');
expect($byType['endpointSecurityPolicy']['restore_mode'])->toBe('preview-only');
expect($byType['securityBaselinePolicy']['restore_mode'])->toBe('preview-only');
});

View File

@ -220,3 +220,76 @@
expect($skippedGroups)->toBeArray();
expect($skippedGroups[0]['id'] ?? null)->toBe('source-group-1');
});
test('restore wizard flags metadata-only snapshots as blocking for restore-enabled types', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
]);
$tenant->makeCurrent();
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-1',
'policy_type' => 'mamAppConfiguration',
'display_name' => 'MAM App Config',
'platform' => 'mobile',
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => '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,
'captured_at' => now(),
'payload' => ['id' => $policy->external_id, 'displayName' => $policy->display_name],
'assignments' => [],
'metadata' => [
'source' => 'metadata_only',
'warnings' => [
'Graph returned 500 for this policy type. Only local metadata was saved; settings and restore are unavailable until Graph works again.',
],
],
]);
$this->mock(GroupResolver::class, function (MockInterface $mock) {
$mock->shouldReceive('resolveGroupIds')
->andReturn([]);
});
$user = User::factory()->create();
$this->actingAs($user);
$component = Livewire::test(CreateRestoreRun::class)
->fillForm([
'backup_set_id' => $backupSet->id,
])
->goToNextWizardStep()
->fillForm([
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id],
])
->goToNextWizardStep()
->callFormComponentAction('check_results', 'run_restore_checks');
$summary = $component->get('data.check_summary');
$results = $component->get('data.check_results');
expect($summary['blocking'] ?? null)->toBe(1);
expect($summary['has_blockers'] ?? null)->toBeTrue();
$metadataOnly = collect($results)->firstWhere('code', 'metadata_only');
expect($metadataOnly)->toBeArray();
expect($metadataOnly['severity'] ?? null)->toBe('blocking');
});

View File

@ -0,0 +1,66 @@
<?php
use App\Models\Policy;
use App\Models\Tenant;
use App\Services\Graph\AssignmentFetcher;
use App\Services\Graph\ScopeTagResolver;
use App\Services\Intune\PolicySnapshotService;
use App\Services\Intune\VersionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('persists metadata-only snapshot metadata on captured versions', function () {
$tenant = Tenant::factory()->create();
$policy = Policy::factory()->for($tenant)->create([
'policy_type' => 'mamAppConfiguration',
'platform' => 'mobile',
'external_id' => 'A_meta_only',
'display_name' => 'MAM Config Meta',
]);
$this->mock(PolicySnapshotService::class, function ($mock) {
$mock->shouldReceive('fetch')
->once()
->andReturn([
'payload' => [
'id' => 'A_meta_only',
'displayName' => 'MAM Config Meta',
'@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration',
],
'metadata' => [
'source' => 'metadata_only',
'original_status' => 500,
'original_failure' => 'InternalServerError: upstream',
],
'warnings' => [
'Snapshot captured from local metadata only (Graph API returned 500).',
],
]);
});
$this->mock(AssignmentFetcher::class, function ($mock) {
$mock->shouldReceive('fetch')->never();
});
$this->mock(ScopeTagResolver::class, function ($mock) {
$mock->shouldReceive('resolve')->never();
});
$service = app(VersionService::class);
$version = $service->captureFromGraph(
tenant: $tenant,
policy: $policy,
createdBy: 'tester@example.test',
includeAssignments: false,
includeScopeTags: false,
);
expect($version->metadata['source'])->toBe('metadata_only');
expect($version->metadata['original_status'])->toBe(500);
expect($version->metadata['original_failure'])->toContain('InternalServerError');
expect($version->metadata['capture_source'])->toBe('version_capture');
expect($version->metadata['warnings'])->toBeArray();
expect($version->metadata['warnings'][0])->toContain('metadata only');
});

View File

@ -0,0 +1,63 @@
<?php
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphLogger;
use App\Services\Graph\MicrosoftGraphClient;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
config()->set('graph.base_url', 'https://graph.microsoft.com');
config()->set('graph.version', 'beta');
config()->set('graph.tenant_id', 'tenant');
config()->set('graph.client_id', 'client');
config()->set('graph.client_secret', 'secret');
config()->set('graph.scope', 'https://graph.microsoft.com/.default');
// Ensure we don't accidentally resolve via supported_policy_types
config()->set('tenantpilot.supported_policy_types', []);
});
it('uses graph contract resource path for applyPolicy', function () {
config()->set('graph_contracts.types.mamAppConfiguration', [
'resource' => 'deviceAppManagement/targetedManagedAppConfigurations',
'allowed_select' => ['id', 'displayName'],
'allowed_expand' => [],
'type_family' => ['#microsoft.graph.targetedManagedAppConfiguration'],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
]);
Http::fake([
'https://login.microsoftonline.com/*' => Http::response([
'access_token' => 'fake-token',
'expires_in' => 3600,
], 200),
'https://graph.microsoft.com/*' => Http::response(['id' => 'A_1'], 200),
]);
$client = new MicrosoftGraphClient(
logger: app(GraphLogger::class),
contracts: app(GraphContractRegistry::class),
);
$client->applyPolicy(
policyType: 'mamAppConfiguration',
policyId: 'A_1',
payload: ['displayName' => 'Test'],
options: ['tenant' => 'tenant', 'client_id' => 'client', 'client_secret' => 'secret'],
);
Http::assertSent(function (Request $request) {
if (! str_contains($request->url(), 'graph.microsoft.com')) {
return false;
}
return str_contains($request->url(), '/beta/deviceAppManagement/targetedManagedAppConfigurations/A_1');
});
});

View File

@ -0,0 +1,45 @@
<?php
use App\Services\Intune\PolicyNormalizer;
use Tests\TestCase;
uses(TestCase::class);
test('managed device app configuration normalizer shows app config keys and values', function () {
$normalizer = app(PolicyNormalizer::class);
$snapshot = [
'id' => 'policy-1',
'displayName' => 'MAMDevice',
'@odata.type' => '#microsoft.graph.iosMobileAppConfiguration',
'settings' => [
[
'appConfigKey' => 'com.microsoft.outlook.EmailProfile.AccountType',
'appConfigKeyType' => 'stringType',
'appConfigKeyValue' => 'ModernAuth',
],
[
'appConfigKey' => 'com.microsoft.outlook.Mail.FocusedInbox',
'appConfigKeyType' => 'booleanType',
'appConfigKeyValue' => 'true',
],
],
];
$normalized = $normalizer->normalize($snapshot, 'managedDeviceAppConfiguration', 'mobile');
$blocks = collect($normalized['settings'] ?? []);
$appConfig = $blocks->firstWhere('title', 'App configuration settings');
expect($appConfig)->not->toBeNull();
expect($appConfig['type'] ?? null)->toBe('table');
$rows = collect($appConfig['rows'] ?? []);
$row = $rows->firstWhere('label', 'com.microsoft.outlook.EmailProfile.AccountType');
expect($row)->not->toBeNull();
expect($row['value'] ?? null)->toBe('ModernAuth');
$boolRow = $rows->firstWhere('label', 'com.microsoft.outlook.Mail.FocusedInbox');
expect($boolRow)->not->toBeNull();
expect($boolRow['value'] ?? null)->toBeTrue();
});

View File

@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
uses(Tests\TestCase::class);
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphLogger;
use App\Services\Graph\MicrosoftGraphClient;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
it('includes contract select fields when listing policies', function () {
Http::fake([
'graph.microsoft.com/*' => Http::response(['value' => []], 200),
]);
$logger = mock(GraphLogger::class);
$logger->shouldReceive('logRequest')->zeroOrMoreTimes()->andReturnNull();
$logger->shouldReceive('logResponse')->zeroOrMoreTimes()->andReturnNull();
$client = new MicrosoftGraphClient(
logger: $logger,
contracts: app(GraphContractRegistry::class),
);
$client->listPolicies('endpointSecurityPolicy', [
'access_token' => 'test-token',
]);
$client->listPolicies('securityBaselinePolicy', [
'access_token' => 'test-token',
]);
$client->listPolicies('settingsCatalogPolicy', [
'access_token' => 'test-token',
]);
Http::assertSent(function (Request $request) {
$url = $request->url();
if (! str_contains($url, '/deviceManagement/configurationPolicies')) {
return false;
}
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
expect($query)->toHaveKey('$select');
$select = (string) $query['$select'];
expect($select)->toContain('technologies')
->and($select)->toContain('templateReference')
->and($select)->toContain('name')
->and($select)->not->toContain('@odata.type');
expect($select)->not->toContain('displayName');
expect($select)->not->toContain('version');
return true;
});
});
it('retries list policies without $select on select/expand parsing errors', function () {
Http::fake([
'graph.microsoft.com/*' => Http::sequence()
->push([
'error' => [
'code' => 'BadRequest',
'message' => "Parsing OData Select and Expand failed: Could not find a property named 'version' on type 'microsoft.graph.deviceManagementConfigurationPolicy'.",
],
], 400)
->push([
'error' => [
'code' => 'BadRequest',
'message' => "Parsing OData Select and Expand failed: Could not find a property named 'version' on type 'microsoft.graph.deviceManagementConfigurationPolicy'.",
],
], 400)
->push(['value' => [['id' => 'policy-1', 'name' => 'Policy One']]], 200),
]);
$logger = mock(GraphLogger::class);
$logger->shouldReceive('logRequest')->zeroOrMoreTimes()->andReturnNull();
$logger->shouldReceive('logResponse')->zeroOrMoreTimes()->andReturnNull();
$client = new MicrosoftGraphClient(
logger: $logger,
contracts: app(GraphContractRegistry::class),
);
$response = $client->listPolicies('settingsCatalogPolicy', [
'access_token' => 'test-token',
]);
expect($response->successful())->toBeTrue();
expect($response->data)->toHaveCount(1);
expect($response->warnings)->toContain('Capability fallback applied: removed $select for compatibility.');
$recorded = Http::recorded();
expect($recorded)->toHaveCount(3);
[$firstRequest] = $recorded[0];
[$secondRequest] = $recorded[1];
[$thirdRequest] = $recorded[2];
parse_str((string) parse_url($firstRequest->url(), PHP_URL_QUERY), $firstQuery);
parse_str((string) parse_url($secondRequest->url(), PHP_URL_QUERY), $secondQuery);
parse_str((string) parse_url($thirdRequest->url(), PHP_URL_QUERY), $thirdQuery);
expect($firstQuery)->toHaveKey('$select');
expect($secondQuery)->toHaveKey('$select');
expect($thirdQuery)->not->toHaveKey('$select');
});
it('paginates list policies when nextLink is present', function () {
$nextLink = 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies?$skiptoken=page2';
Http::fake([
'graph.microsoft.com/*' => Http::sequence()
->push([
'value' => [
['id' => 'policy-1', 'name' => 'Policy One'],
],
'@odata.nextLink' => $nextLink,
], 200)
->push([
'value' => [
['id' => 'policy-2', 'name' => 'Policy Two'],
],
], 200),
]);
$logger = mock(GraphLogger::class);
$logger->shouldReceive('logRequest')->zeroOrMoreTimes()->andReturnNull();
$logger->shouldReceive('logResponse')->zeroOrMoreTimes()->andReturnNull();
$client = new MicrosoftGraphClient(
logger: $logger,
contracts: app(GraphContractRegistry::class),
);
$response = $client->listPolicies('settingsCatalogPolicy', [
'access_token' => 'test-token',
]);
expect($response->successful())->toBeTrue();
expect($response->data)->toHaveCount(2);
expect(collect($response->data)->pluck('id')->all())->toMatchArray(['policy-1', 'policy-2']);
$recorded = Http::recorded();
expect($recorded)->toHaveCount(2);
[$firstRequest] = $recorded[0];
[$secondRequest] = $recorded[1];
expect($firstRequest->url())->toContain('/deviceManagement/configurationPolicies');
expect($secondRequest->url())->toBe($nextLink);
});

View File

@ -0,0 +1,64 @@
<?php
use App\Models\Policy;
use App\Models\Tenant;
use App\Services\Graph\AssignmentFetcher;
use App\Services\Graph\AssignmentFilterResolver;
use App\Services\Graph\GroupResolver;
use App\Services\Graph\ScopeTagResolver;
use App\Services\Intune\PolicyCaptureOrchestrator;
use App\Services\Intune\PolicySnapshotService;
use App\Services\Intune\VersionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('capture returns failure when snapshot fetch fails (no exception)', function () {
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-1',
'app_client_id' => 'client-1',
'app_client_secret' => 'secret-1',
'is_current' => true,
]);
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'policy_type' => 'mamAppConfiguration',
'external_id' => 'A_f38e7f58-ac7c-455d-bb0e-f56bf1b3890e',
'display_name' => 'MAM Example',
'platform' => 'mobile',
]);
$snapshotService = Mockery::mock(PolicySnapshotService::class);
$snapshotService
->shouldReceive('fetch')
->once()
->andReturn([
'failure' => [
'reason' => 'InternalServerError: upstream',
'status' => 500,
],
]);
$orchestrator = new PolicyCaptureOrchestrator(
versionService: Mockery::mock(VersionService::class),
snapshotService: $snapshotService,
assignmentFetcher: Mockery::mock(AssignmentFetcher::class),
groupResolver: Mockery::mock(GroupResolver::class),
assignmentFilterResolver: Mockery::mock(AssignmentFilterResolver::class),
scopeTagResolver: Mockery::mock(ScopeTagResolver::class),
);
$result = $orchestrator->capture(
policy: $policy,
tenant: $tenant,
includeAssignments: true,
includeScopeTags: true,
createdBy: 'admin@example.test',
);
expect($result)->toHaveKey('failure');
expect($result['failure']['status'])->toBe(500);
expect($result['failure']['reason'])->toContain('InternalServerError');
});

View File

@ -89,6 +89,68 @@ public function request(string $method, string $path, array $options = []): Grap
}
}
class ConfigurationPolicySettingsSnapshotGraphClient 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];
return new GraphResponse(success: true, data: [
'payload' => [
'id' => $policyId,
'name' => 'Endpoint Security Alpha',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
],
]);
}
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/configurationPolicies/') && str_ends_with($path, '/settings')) {
return new GraphResponse(success: true, data: [
'value' => [
[
'id' => 'setting-1',
'settingInstance' => [
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance',
'settingDefinitionId' => 'device_vendor_msft_policy_config_firewall_policy_alpha',
'simpleSettingValue' => [
'value' => true,
],
],
],
],
]);
}
return new GraphResponse(success: true, data: []);
}
}
it('hydrates compliance policy scheduled actions into snapshots', function () {
$client = new PolicySnapshotGraphClient;
app()->instance(GraphClientInterface::class, $client);
@ -125,6 +187,45 @@ public function request(string $method, string $path, array $options = []): Grap
->toBe('scheduledActionsForRule($expand=scheduledActionConfigurations)');
});
it('hydrates configuration policy settings into snapshots', function (string $policyType) {
$client = new ConfigurationPolicySettingsSnapshotGraphClient;
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-endpoint-security',
'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' => 'esp-123',
'policy_type' => $policyType,
'display_name' => 'Endpoint Security Alpha',
'platform' => 'windows',
]);
$service = app(PolicySnapshotService::class);
$result = $service->fetch($tenant, $policy);
expect($result)->toHaveKey('payload');
expect($result['payload'])->toHaveKey('settings');
expect($result['payload']['settings'])->toHaveCount(1);
expect($result['metadata']['settings_hydration'] ?? null)->toBe('complete');
$paths = collect($client->requests)
->filter(fn (array $entry): bool => ($entry[0] ?? null) === 'GET')
->map(fn (array $entry): string => (string) ($entry[1] ?? ''))
->values();
expect($paths->contains(fn (string $path): bool => str_contains($path, 'deviceManagement/configurationPolicies/esp-123/settings')))->toBeTrue();
})->with([
'endpointSecurityPolicy',
'securityBaselinePolicy',
]);
it('filters mobile app snapshots to metadata-only keys', function () {
$client = new PolicySnapshotGraphClient;
app()->instance(GraphClientInterface::class, $client);
@ -170,6 +271,123 @@ public function request(string $method, string $path, array $options = []): Grap
expect($client->requests[0][3]['select'])->not->toContain('@odata.type');
});
test('falls back to metadata-only snapshot when mamAppConfiguration returns 500', function () {
$client = Mockery::mock(\App\Services\Graph\GraphClientInterface::class);
$client->shouldReceive('getPolicy')
->once()
->andThrow(new \App\Services\Graph\GraphException('InternalServerError: upstream', 500));
app()->instance(\App\Services\Graph\GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-mam-fallback',
'app_client_id' => 'client-123',
'app_client_secret' => 'secret-123',
'is_current' => true,
]);
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'A_fallback-policy',
'policy_type' => 'mamAppConfiguration',
'display_name' => 'MAM Config Alpha',
'platform' => 'iOS',
]);
$service = app(\App\Services\Intune\PolicySnapshotService::class);
$result = $service->fetch($tenant, $policy);
expect($result)->toHaveKey('payload');
expect($result)->toHaveKey('metadata');
expect($result)->toHaveKey('warnings');
expect($result['payload']['id'])->toBe('A_fallback-policy');
expect($result['payload']['displayName'])->toBe('MAM Config Alpha');
expect($result['payload']['@odata.type'])->toBe('#microsoft.graph.targetedManagedAppConfiguration');
expect($result['payload']['platform'])->toBe('iOS');
expect($result['metadata']['source'])->toBe('metadata_only');
expect($result['metadata']['original_status'])->toBe(500);
expect($result['warnings'])->toHaveCount(1);
expect($result['warnings'][0])->toContain('Snapshot captured from local metadata only');
expect($result['warnings'][0])->toContain('Restore preview available, full restore not possible');
});
test('does not fallback to metadata for non-5xx errors', function () {
$client = Mockery::mock(\App\Services\Graph\GraphClientInterface::class);
$client->shouldReceive('getPolicy')
->once()
->andThrow(new \App\Services\Graph\GraphException('NotFound', 404));
app()->instance(\App\Services\Graph\GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-404',
'app_client_id' => 'client-123',
'app_client_secret' => 'secret-123',
'is_current' => true,
]);
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'A_missing',
'policy_type' => 'mamAppConfiguration',
'display_name' => 'Missing Policy',
'platform' => 'iOS',
]);
$service = app(\App\Services\Intune\PolicySnapshotService::class);
$result = $service->fetch($tenant, $policy);
expect($result)->toHaveKey('failure');
expect($result['failure']['status'])->toBe(404);
expect($result['failure']['reason'])->toContain('NotFound');
});
test('falls back to metadata-only when graph client returns failed response for mamAppConfiguration', function () {
$client = Mockery::mock(\App\Services\Graph\GraphClientInterface::class);
$client->shouldReceive('getPolicy')
->once()
->andReturn(new \App\Services\Graph\GraphResponse(
success: false,
data: [
'error' => [
'code' => 'InternalServerError',
'message' => 'Upstream MAM failure',
],
],
status: 500,
errors: [['code' => 'InternalServerError', 'message' => 'Upstream MAM failure']],
meta: [
'client_request_id' => 'client-req-1',
'request_id' => 'req-1',
],
));
app()->instance(\App\Services\Graph\GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-mam-fallback-response',
'app_client_id' => 'client-123',
'app_client_secret' => 'secret-123',
'is_current' => true,
]);
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'A_resp_fallback',
'policy_type' => 'mamAppConfiguration',
'display_name' => 'MAM Config Response',
'platform' => 'iOS',
]);
$service = app(\App\Services\Intune\PolicySnapshotService::class);
$result = $service->fetch($tenant, $policy);
expect($result)->toHaveKey('payload');
expect($result['metadata']['source'])->toBe('metadata_only');
expect($result['metadata']['original_status'])->toBe(500);
expect($result['metadata']['original_failure'])->toContain('InternalServerError');
});
class WindowsUpdateRingSnapshotGraphClient implements GraphClientInterface
{
public array $requests = [];
@ -251,3 +469,78 @@ public function request(string $method, string $path, array $options = []): Grap
expect($result['payload']['featureUpdatesDeferralPeriodInDays'])->toBe(14);
expect($result['metadata']['properties_hydration'] ?? null)->toBe('complete');
});
class FailedSnapshotGraphClient implements GraphClientInterface
{
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(success: true, data: []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(
success: false,
data: [],
status: 500,
errors: [],
warnings: [],
meta: [
'error_code' => 'InternalServerError',
'error_message' => 'An internal server error has occurred',
'request_id' => 'req-123',
'client_request_id' => 'client-456',
],
);
}
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
{
return new GraphResponse(success: true, data: []);
}
}
it('returns actionable reasons when graph snapshot fails', function () {
app()->instance(GraphClientInterface::class, new FailedSnapshotGraphClient);
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-failure',
'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' => 'mam-123',
'policy_type' => 'deviceCompliancePolicy',
'display_name' => 'Compliance Config',
'platform' => 'mobile',
]);
$service = app(PolicySnapshotService::class);
$result = $service->fetch($tenant, $policy);
expect($result)->toHaveKey('failure');
expect($result['failure']['status'])->toBe(500);
expect($result['failure']['reason'])->toContain('InternalServerError');
expect($result['failure']['reason'])->toContain('An internal server error has occurred');
expect($result['failure']['reason'])->toContain('client_request_id=client-456');
expect($result['failure']['reason'])->toContain('request_id=req-123');
});

View File

@ -31,3 +31,139 @@
expect($rows)->toHaveCount(1);
expect($rows[0]['definition_id'] ?? null)->toBe('device_vendor_msft_policy_config_defender_allowrealtimemonitoring');
});
it('builds a settings table for endpoint security configuration policies', function (string $policyType) {
$normalizer = app(SettingsCatalogPolicyNormalizer::class);
$snapshot = [
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'settings' => [
[
'id' => 's1',
'settingInstance' => [
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance',
'settingDefinitionId' => 'device_vendor_msft_policy_config_defender_allowrealtimemonitoring',
'simpleSettingValue' => [
'value' => 1,
],
],
],
],
];
$normalized = $normalizer->normalize($snapshot, $policyType, 'windows');
$rows = $normalized['settings_table']['rows'] ?? [];
expect($rows)->toHaveCount(1);
expect($rows[0]['definition_id'] ?? null)->toBe('device_vendor_msft_policy_config_defender_allowrealtimemonitoring');
})->with([
'endpointSecurityPolicy',
'securityBaselinePolicy',
]);
it('prettifies endpoint security firewall rules settings for display', function () {
$normalizer = app(SettingsCatalogPolicyNormalizer::class);
$groupDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}';
$nameDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}_displayname';
$directionDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}_direction';
$actionDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}_action';
$interfaceTypesDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}_interfacetypes';
$snapshot = [
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'templateReference' => [
'templateFamily' => 'endpointSecurityFirewall',
'templateDisplayName' => 'Windows Firewall Rules',
'templateDisplayVersion' => 'Version 1',
],
'settings' => [
[
'id' => 'rule-1',
'settingInstance' => [
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationGroupSettingCollectionInstance',
'settingDefinitionId' => $groupDefinitionId,
'groupSettingCollectionValue' => [
[
'children' => [
[
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance',
'settingDefinitionId' => $nameDefinitionId,
'simpleSettingValue' => [
'value' => 'Test0',
],
],
[
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance',
'settingDefinitionId' => $directionDefinitionId,
'choiceSettingValue' => [
'value' => "{$directionDefinitionId}_in",
],
],
[
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance',
'settingDefinitionId' => $actionDefinitionId,
'choiceSettingValue' => [
'value' => "{$actionDefinitionId}_allow",
],
],
[
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationChoiceSettingCollectionInstance',
'settingDefinitionId' => $interfaceTypesDefinitionId,
'choiceSettingCollectionValue' => [
[
'value' => "{$interfaceTypesDefinitionId}_lan",
'children' => [],
],
[
'value' => "{$interfaceTypesDefinitionId}_remoteaccess",
'children' => [],
],
],
],
],
],
],
],
],
],
];
$normalized = $normalizer->normalize($snapshot, 'endpointSecurityPolicy', 'windows');
$rows = collect($normalized['settings_table']['rows'] ?? []);
$groupRow = $rows->firstWhere('definition_id', $groupDefinitionId);
expect($groupRow)->not->toBeNull();
expect($groupRow['category'] ?? null)->toBe('Windows Firewall Rules');
expect($groupRow['definition'] ?? null)->toBe('Firewall rule');
expect($groupRow['data_type'] ?? null)->toBe('Group');
expect($groupRow['value'] ?? null)->toBe('(group)');
$nameRow = $rows->firstWhere('definition_id', $nameDefinitionId);
expect($nameRow)->not->toBeNull();
expect($nameRow['category'] ?? null)->toBe('Windows Firewall Rules');
expect($nameRow['definition'] ?? null)->toBe('Name');
expect($nameRow['value'] ?? null)->toBe('Test0');
$directionRow = $rows->firstWhere('definition_id', $directionDefinitionId);
expect($directionRow)->not->toBeNull();
expect($directionRow['category'] ?? null)->toBe('Windows Firewall Rules');
expect($directionRow['definition'] ?? null)->toBe('Direction');
expect($directionRow['data_type'] ?? null)->toBe('Choice');
expect($directionRow['value'] ?? null)->toBe('Inbound');
$actionRow = $rows->firstWhere('definition_id', $actionDefinitionId);
expect($actionRow)->not->toBeNull();
expect($actionRow['category'] ?? null)->toBe('Windows Firewall Rules');
expect($actionRow['definition'] ?? null)->toBe('Action');
expect($actionRow['data_type'] ?? null)->toBe('Choice');
expect($actionRow['value'] ?? null)->toBe('Allow');
$interfaceTypesRow = $rows->firstWhere('definition_id', $interfaceTypesDefinitionId);
expect($interfaceTypesRow)->not->toBeNull();
expect($interfaceTypesRow['category'] ?? null)->toBe('Windows Firewall Rules');
expect($interfaceTypesRow['definition'] ?? null)->toBe('Interface types');
expect($interfaceTypesRow['data_type'] ?? null)->toBe('Choice');
expect($interfaceTypesRow['value'] ?? null)->toBe('LAN, Remote access');
});