From 286d3c596bac884ad3e2b32de746fd3b496a63db Mon Sep 17 00:00:00 2001 From: ahmido Date: Thu, 1 Jan 2026 10:44:17 +0000 Subject: [PATCH] feat/012-windows-update-rings (#18) Created a safe session branch, committed everything, fast-forward merged back into feat/012-windows-update-rings, then pushed. Commit: 074a656 feat(rings): update rings + update profiles Push is done; upstream tracking is se Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/18 --- app/Models/Tenant.php | 8 +- app/Providers/AppServiceProvider.php | 6 + app/Services/Intune/PolicySnapshotService.php | 61 +++++ app/Services/Intune/RestoreService.php | 17 ++ .../WindowsFeatureUpdateProfileNormalizer.php | 107 +++++++++ .../WindowsQualityUpdateProfileNormalizer.php | 83 +++++++ .../Intune/WindowsUpdateRingNormalizer.php | 137 +++++++++++ .../Concerns/InteractsWithODataTypes.php | 8 + config/graph_contracts.php | 53 +++++ config/tenantpilot.php | 26 ++- phpunit.xml | 2 + specs/012-windows-update-rings/plan.md | 18 ++ specs/012-windows-update-rings/spec.md | 77 +++++++ specs/012-windows-update-rings/tasks.md | 26 +++ .../Feature/BulkProgressNotificationTest.php | 4 +- ...AppProtectionPolicySettingsDisplayTest.php | 2 +- tests/Feature/Filament/BackupCreationTest.php | 2 +- tests/Feature/Filament/HousekeepingTest.php | 12 + .../Filament/MalformedSnapshotWarningTest.php | 2 +- .../Filament/ODataTypeMismatchTest.php | 2 +- tests/Feature/Filament/PolicyListingTest.php | 2 +- .../Filament/PolicySettingsDisplayTest.php | 3 +- .../PolicyVersionReadableLayoutTest.php | 3 +- .../PolicyVersionScopeTagsDisplayTest.php | 3 +- .../Filament/PolicyVersionSettingsTest.php | 3 +- tests/Feature/Filament/PolicyVersionTest.php | 4 +- .../PolicyViewSettingsCatalogReadableTest.php | 20 +- ...ingsCatalogPolicyNormalizedDisplayTest.php | 3 +- .../SettingsCatalogPolicySyncTest.php | 7 +- ...SettingsCatalogSettingsTableRenderTest.php | 3 +- .../WindowsUpdateProfilesRestoreTest.php | 213 ++++++++++++++++++ .../Filament/WindowsUpdateRingPolicyTest.php | 77 +++++++ .../Filament/WindowsUpdateRingRestoreTest.php | 151 +++++++++++++ .../AppProtectionPolicySyncFilteringTest.php | 2 +- .../Jobs/PolicySyncIgnoredRevivalTest.php | 4 +- tests/Feature/PolicySyncServiceTest.php | 77 +++++++ tests/TestCase.php | 11 +- tests/Unit/FoundationSnapshotServiceTest.php | 2 +- tests/Unit/PolicySnapshotServiceTest.php | 82 +++++++ tests/Unit/TenantCurrentTest.php | 24 ++ 40 files changed, 1298 insertions(+), 49 deletions(-) create mode 100644 app/Services/Intune/WindowsFeatureUpdateProfileNormalizer.php create mode 100644 app/Services/Intune/WindowsQualityUpdateProfileNormalizer.php create mode 100644 app/Services/Intune/WindowsUpdateRingNormalizer.php create mode 100644 specs/012-windows-update-rings/plan.md create mode 100644 specs/012-windows-update-rings/spec.md create mode 100644 specs/012-windows-update-rings/tasks.md create mode 100644 tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php create mode 100644 tests/Feature/Filament/WindowsUpdateRingPolicyTest.php create mode 100644 tests/Feature/Filament/WindowsUpdateRingRestoreTest.php create mode 100644 tests/Feature/PolicySyncServiceTest.php diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index bc0a134..3944280 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -104,13 +104,17 @@ public function makeCurrent(): void DB::transaction(function () { static::activeQuery()->update(['is_current' => false]); - $this->forceFill(['is_current' => true])->save(); + static::query() + ->whereKey($this->getKey()) + ->update(['is_current' => true]); }); + + $this->forceFill(['is_current' => true]); } public static function current(): self { - $envTenantId = env('INTUNE_TENANT_ID') ?: null; + $envTenantId = getenv('INTUNE_TENANT_ID') ?: null; if ($envTenantId) { $tenant = static::activeQuery() diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 517a762..dbf8384 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -10,6 +10,9 @@ use App\Services\Intune\DeviceConfigurationPolicyNormalizer; use App\Services\Intune\GroupPolicyConfigurationNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer; +use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer; +use App\Services\Intune\WindowsQualityUpdateProfileNormalizer; +use App\Services\Intune\WindowsUpdateRingNormalizer; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -40,6 +43,9 @@ public function register(): void DeviceConfigurationPolicyNormalizer::class, GroupPolicyConfigurationNormalizer::class, SettingsCatalogPolicyNormalizer::class, + WindowsFeatureUpdateProfileNormalizer::class, + WindowsQualityUpdateProfileNormalizer::class, + WindowsUpdateRingNormalizer::class, ], 'policy-type-normalizers' ); diff --git a/app/Services/Intune/PolicySnapshotService.php b/app/Services/Intune/PolicySnapshotService.php index c173b1b..3e6b82a 100644 --- a/app/Services/Intune/PolicySnapshotService.php +++ b/app/Services/Intune/PolicySnapshotService.php @@ -77,6 +77,16 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null $metadata = Arr::except($response->data, ['payload']); $metadataWarnings = $metadata['warnings'] ?? []; + if ($policy->policy_type === 'windowsUpdateRing') { + [$payload, $metadata] = $this->hydrateWindowsUpdateRing( + tenantIdentifier: $tenantIdentifier, + tenant: $tenant, + policyId: $policy->external_id, + payload: is_array($payload) ? $payload : [], + metadata: $metadata, + ); + } + if ($policy->policy_type === 'settingsCatalogPolicy') { [$payload, $metadata] = $this->hydrateSettingsCatalog( tenantIdentifier: $tenantIdentifier, @@ -152,6 +162,57 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null ]; } + /** + * Hydrate Windows Update Ring payload via derived type cast to capture + * windowsUpdateForBusinessConfiguration-specific properties. + * + * @return array{0:array,1:array} + */ + private function hydrateWindowsUpdateRing(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array + { + $odataType = $payload['@odata.type'] ?? null; + $castSegment = $this->deriveTypeCastSegment($odataType); + + if ($castSegment === null) { + $metadata['properties_hydration'] = 'skipped'; + + return [$payload, $metadata]; + } + + $castPath = sprintf('deviceManagement/deviceConfigurations/%s/%s', urlencode($policyId), $castSegment); + + $response = $this->graphClient->request('GET', $castPath, [ + 'tenant' => $tenantIdentifier, + 'client_id' => $tenant->app_client_id, + 'client_secret' => $tenant->app_client_secret, + ]); + + if ($response->failed() || ! is_array($response->data)) { + $metadata['properties_hydration'] = 'failed'; + + return [$payload, $metadata]; + } + + $metadata['properties_hydration'] = 'complete'; + + return [array_merge($payload, $response->data), $metadata]; + } + + private function deriveTypeCastSegment(mixed $odataType): ?string + { + if (! is_string($odataType) || $odataType === '') { + return null; + } + + if (! str_starts_with($odataType, '#')) { + return null; + } + + $segment = ltrim($odataType, '#'); + + return $segment !== '' ? $segment : null; + } + private function isMetadataOnlyPolicyType(string $policyType): bool { foreach (config('tenantpilot.supported_policy_types', []) as $type) { diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index e9e02d1..85f23fe 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -555,6 +555,23 @@ public function execute( $payload, $graphOptions + ['method' => $updateMethod] ); + } elseif ($item->policy_type === 'windowsUpdateRing') { + $odataType = $this->resolvePayloadString($originalPayload, ['@odata.type']); + $castSegment = $odataType && str_starts_with($odataType, '#') + ? ltrim($odataType, '#') + : 'microsoft.graph.windowsUpdateForBusinessConfiguration'; + + $updatePath = sprintf( + 'deviceManagement/deviceConfigurations/%s/%s', + urlencode($item->policy_identifier), + $castSegment, + ); + + $response = $this->graphClient->request( + $updateMethod, + $updatePath, + ['json' => $payload] + Arr::except($graphOptions, ['platform']) + ); } else { $response = $this->graphClient->applyPolicy( $item->policy_type, diff --git a/app/Services/Intune/WindowsFeatureUpdateProfileNormalizer.php b/app/Services/Intune/WindowsFeatureUpdateProfileNormalizer.php new file mode 100644 index 0000000..4e95d52 --- /dev/null +++ b/app/Services/Intune/WindowsFeatureUpdateProfileNormalizer.php @@ -0,0 +1,107 @@ +>, settings_table?: array, warnings: array} + */ + 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'][] = $this->buildFeatureUpdateBlock($snapshot); + $normalized['settings'] = array_values(array_filter($normalized['settings'])); + + return $normalized; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->normalize($snapshot, $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } + + private function buildFeatureUpdateBlock(array $snapshot): ?array + { + $entries = []; + + $displayName = Arr::get($snapshot, 'displayName'); + + if (is_string($displayName) && $displayName !== '') { + $entries[] = ['key' => 'Name', 'value' => $displayName]; + } + + $version = Arr::get($snapshot, 'featureUpdateVersion'); + + if (is_string($version) && $version !== '') { + $entries[] = ['key' => 'Feature update version', 'value' => $version]; + } + + $rollout = Arr::get($snapshot, 'rolloutSettings'); + + if (is_array($rollout)) { + $start = $this->formatDateTime($rollout['offerStartDateTimeInUTC'] ?? null); + $end = $this->formatDateTime($rollout['offerEndDateTimeInUTC'] ?? null); + $interval = $rollout['offerIntervalInDays'] ?? null; + + if ($start !== null) { + $entries[] = ['key' => 'Rollout start', 'value' => $start]; + } + + if ($end !== null) { + $entries[] = ['key' => 'Rollout end', 'value' => $end]; + } + + if ($interval !== null) { + $entries[] = ['key' => 'Rollout interval (days)', 'value' => $interval]; + } + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Feature Update Profile', + 'entries' => $entries, + ]; + } + + private function formatDateTime(mixed $value): ?string + { + if (! is_string($value) || $value === '') { + return null; + } + + try { + return CarbonImmutable::parse($value)->toDateTimeString(); + } catch (\Throwable) { + return $value; + } + } +} diff --git a/app/Services/Intune/WindowsQualityUpdateProfileNormalizer.php b/app/Services/Intune/WindowsQualityUpdateProfileNormalizer.php new file mode 100644 index 0000000..b1ff849 --- /dev/null +++ b/app/Services/Intune/WindowsQualityUpdateProfileNormalizer.php @@ -0,0 +1,83 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform); + + if ($snapshot === []) { + return $normalized; + } + + $block = $this->buildQualityUpdateBlock($snapshot); + + if ($block !== null) { + $normalized['settings'][] = $block; + $normalized['settings'] = array_values(array_filter($normalized['settings'])); + } + + return $normalized; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->normalize($snapshot, $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } + + private function buildQualityUpdateBlock(array $snapshot): ?array + { + $entries = []; + + $displayName = Arr::get($snapshot, 'displayName'); + + if (is_string($displayName) && $displayName !== '') { + $entries[] = ['key' => 'Name', 'value' => $displayName]; + } + + $release = Arr::get($snapshot, 'releaseDateDisplayName'); + + if (is_string($release) && $release !== '') { + $entries[] = ['key' => 'Release', 'value' => $release]; + } + + $content = Arr::get($snapshot, 'deployableContentDisplayName'); + + if (is_string($content) && $content !== '') { + $entries[] = ['key' => 'Deployable content', 'value' => $content]; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Quality Update Profile', + 'entries' => $entries, + ]; + } +} diff --git a/app/Services/Intune/WindowsUpdateRingNormalizer.php b/app/Services/Intune/WindowsUpdateRingNormalizer.php new file mode 100644 index 0000000..66ec7a0 --- /dev/null +++ b/app/Services/Intune/WindowsUpdateRingNormalizer.php @@ -0,0 +1,137 @@ +>, settings_table?: array, warnings: array} + */ + 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'], + fn (array $block) => strtolower((string) ($block['title'] ?? '')) !== 'general' + )); + + $normalized['settings'][] = $this->buildUpdateSettingsBlock($snapshot); + $normalized['settings'][] = $this->buildUserExperienceBlock($snapshot); + $normalized['settings'][] = $this->buildAdvancedOptionsBlock($snapshot); + + $normalized['settings'] = array_values(array_filter($normalized['settings'])); + + return $normalized; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->normalize($snapshot, $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } + + private function buildUpdateSettingsBlock(array $snapshot): ?array + { + $keys = [ + 'allowWindows11Upgrade', + 'automaticUpdateMode', + 'featureUpdatesDeferralPeriodInDays', + 'featureUpdatesPaused', + 'featureUpdatesPauseExpiryDateTime', + 'qualityUpdatesDeferralPeriodInDays', + 'qualityUpdatesPaused', + 'qualityUpdatesPauseExpiryDateTime', + 'updateWindowsDeviceDriverExclusion', + ]; + + return $this->buildBlock('Update Settings', $snapshot, $keys); + } + + private function buildUserExperienceBlock(array $snapshot): ?array + { + $keys = [ + 'deadlineForFeatureUpdatesInDays', + 'deadlineForQualityUpdatesInDays', + 'deadlineGracePeriodInDays', + 'gracePeriodInDays', + 'restartActiveHoursStart', + 'restartActiveHoursEnd', + 'setActiveHours', + 'userPauseAccess', + 'userCheckAccess', + ]; + + return $this->buildBlock('User Experience', $snapshot, $keys); + } + + private function buildAdvancedOptionsBlock(array $snapshot): ?array + { + $keys = [ + 'deliveryOptimizationMode', + 'prereleaseFeatures', + 'servicingChannel', + 'microsoftUpdateServiceAllowed', + ]; + + return $this->buildBlock('Advanced Options', $snapshot, $keys); + } + + private function buildBlock(string $title, array $snapshot, array $keys): ?array + { + $entries = []; + + foreach ($keys as $key) { + if (array_key_exists($key, $snapshot)) { + $entries[] = [ + 'key' => Str::headline($key), + 'value' => $this->formatValue($snapshot[$key]), + ]; + } + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => $title, + 'entries' => $entries, + ]; + } + + private function formatValue(mixed $value): mixed + { + if (is_bool($value)) { + return $value ? 'Yes' : 'No'; + } + + if (is_array($value)) { + return json_encode($value, JSON_PRETTY_PRINT); + } + + return $value; + } +} diff --git a/app/Support/Concerns/InteractsWithODataTypes.php b/app/Support/Concerns/InteractsWithODataTypes.php index eb5274a..37ba8dc 100644 --- a/app/Support/Concerns/InteractsWithODataTypes.php +++ b/app/Support/Concerns/InteractsWithODataTypes.php @@ -29,6 +29,14 @@ protected static function odataTypeMap(): array 'windows' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', 'all' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', ], + 'windowsFeatureUpdateProfile' => [ + 'windows' => '#microsoft.graph.windowsFeatureUpdateProfile', + 'all' => '#microsoft.graph.windowsFeatureUpdateProfile', + ], + 'windowsQualityUpdateProfile' => [ + 'windows' => '#microsoft.graph.windowsQualityUpdateProfile', + 'all' => '#microsoft.graph.windowsQualityUpdateProfile', + ], 'deviceCompliancePolicy' => [ 'windows' => '#microsoft.graph.windows10CompliancePolicy', 'ios' => '#microsoft.graph.iosCompliancePolicy', diff --git a/config/graph_contracts.php b/config/graph_contracts.php index 6c6ac67..2b877e9 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -143,6 +143,13 @@ 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'update_strip_keys' => [ + 'version', + 'qualityUpdatesPauseStartDate', + 'featureUpdatesPauseStartDate', + 'qualityUpdatesWillBeRolledBack', + 'featureUpdatesWillBeRolledBack', + ], 'assignments_list_path' => '/deviceManagement/deviceConfigurations/{id}/assignments', 'assignments_create_path' => '/deviceManagement/deviceConfigurations/{id}/assign', 'assignments_create_method' => 'POST', @@ -153,6 +160,52 @@ 'supports_scope_tags' => true, 'scope_tag_field' => 'roleScopeTagIds', ], + 'windowsFeatureUpdateProfile' => [ + 'resource' => 'deviceManagement/windowsFeatureUpdateProfiles', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.windowsFeatureUpdateProfile', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'update_strip_keys' => [ + 'deployableContentDisplayName', + 'endOfSupportDate', + ], + 'assignments_list_path' => '/deviceManagement/windowsFeatureUpdateProfiles/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/windowsFeatureUpdateProfiles/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/windowsFeatureUpdateProfiles/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/windowsFeatureUpdateProfiles/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + ], + 'windowsQualityUpdateProfile' => [ + 'resource' => 'deviceManagement/windowsQualityUpdateProfiles', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.windowsQualityUpdateProfile', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'update_strip_keys' => [ + 'releaseDateDisplayName', + 'deployableContentDisplayName', + ], + 'assignments_list_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + ], 'deviceCompliancePolicy' => [ 'resource' => 'deviceManagement/deviceCompliancePolicies', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], diff --git a/config/tenantpilot.php b/config/tenantpilot.php index 1e214df..2e8c4b0 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -8,7 +8,7 @@ 'category' => 'Configuration', 'platform' => 'all', 'endpoint' => 'deviceManagement/deviceConfigurations', - 'filter' => "@odata.type ne '#microsoft.graph.windowsUpdateForBusinessConfiguration'", + 'filter' => "not isof('microsoft.graph.windowsUpdateForBusinessConfiguration')", 'backup' => 'full', 'restore' => 'enabled', 'risk' => 'medium', @@ -39,11 +39,31 @@ 'category' => 'Update Management', 'platform' => 'windows', 'endpoint' => 'deviceManagement/deviceConfigurations', - 'filter' => "@odata.type eq '#microsoft.graph.windowsUpdateForBusinessConfiguration'", + 'filter' => "isof('microsoft.graph.windowsUpdateForBusinessConfiguration')", 'backup' => 'full', 'restore' => 'enabled', 'risk' => 'medium-high', ], + [ + 'type' => 'windowsFeatureUpdateProfile', + 'label' => 'Feature Updates (Windows)', + 'category' => 'Update Management', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/windowsFeatureUpdateProfiles', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'high', + ], + [ + 'type' => 'windowsQualityUpdateProfile', + 'label' => 'Quality Updates (Windows)', + 'category' => 'Update Management', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/windowsQualityUpdateProfiles', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'high', + ], [ 'type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance', @@ -130,7 +150,7 @@ 'category' => 'Enrollment', 'platform' => 'all', 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', - 'filter' => "@odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'", + 'filter' => "isof('microsoft.graph.windows10EnrollmentCompletionPageConfiguration')", 'backup' => 'full', 'restore' => 'enabled', 'risk' => 'medium', diff --git a/phpunit.xml b/phpunit.xml index d703241..75c4ea3 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,7 +18,9 @@ + + diff --git a/specs/012-windows-update-rings/plan.md b/specs/012-windows-update-rings/plan.md new file mode 100644 index 0000000..624d738 --- /dev/null +++ b/specs/012-windows-update-rings/plan.md @@ -0,0 +1,18 @@ +# Implementation Plan: Windows Update Rings (012) + +**Branch**: `feat/012-windows-update-rings` +**Date**: 2025-12-31 +**Spec Source**: [spec.md](./spec.md) + +## Summary +Make `windowsUpdateRing` snapshots/restores accurate by correctly capturing and applying its settings, and present a readable normalized view in Filament. + +Also add coverage for Windows Feature Update Profiles (`windowsFeatureUpdateProfile`) and Windows Quality Update Profiles (`windowsQualityUpdateProfile`) so they can be synced, snapshotted, restored, and displayed in a readable normalized format. + +## Execution Steps +1. **Graph contract verification**: Ensure `config/graph_contracts.php` entry for `windowsUpdateRing` is correct and complete. +2. **Snapshot capture hydration**: Extend `PolicySnapshotService` to correctly hydrate `windowsUpdateForBusinessConfiguration` settings into the policy payload. +3. **Restore**: Extend `RestoreService` to apply `windowsUpdateRing` settings from a snapshot to the target policy in Intune. +4. **UI normalization**: Add a dedicated normalizer for `windowsUpdateRing` that renders configured settings as readable rows in the Filament UI. +5. **Feature/Quality Update Profiles**: Add Graph contract + supported types, and normalizers for `windowsFeatureUpdateProfile` and `windowsQualityUpdateProfile`. +6. **Tests + formatting**: Add targeted Pest tests for sync filters/types, snapshot/normalized display (as applicable), and restore payload sanitization. Run `./vendor/bin/pint --dirty` and the affected tests. diff --git a/specs/012-windows-update-rings/spec.md b/specs/012-windows-update-rings/spec.md new file mode 100644 index 0000000..1f05b92 --- /dev/null +++ b/specs/012-windows-update-rings/spec.md @@ -0,0 +1,77 @@ +# Feature Specification: Windows Update Rings (012) + +**Feature Branch**: `feat/012-windows-update-rings` +**Created**: 2025-12-31 +**Status**: Draft +**Input**: `config/graph_contracts.php` (windowsUpdateRing scope) + +## Overview +Add reliable coverage for **Windows Update Rings** (`windowsUpdateRing`) in the existing inventory/backup/version/restore flows. + +This feature also extends coverage to **Windows Feature Update Profiles** ("Feature Updates"), which are managed under the `deviceManagement/windowsFeatureUpdateProfiles` endpoint and have `@odata.type` `#microsoft.graph.windowsFeatureUpdateProfile`. + +This feature also extends coverage to **Windows Quality Update Profiles** ("Quality Updates"), which are managed under the `deviceManagement/windowsQualityUpdateProfiles` endpoint and have `@odata.type` `#microsoft.graph.windowsQualityUpdateProfile`. + +This policy type is defined in `graph_contracts.php` and uses the `deviceManagement/deviceConfigurations` endpoint, identified by the `@odata.type` `#microsoft.graph.windowsUpdateForBusinessConfiguration`. This feature will focus on implementing the necessary UI normalization and ensuring the sync, backup, versioning, and restore flows function correctly for this policy type. + +## In Scope +- Policy type: `windowsUpdateRing` +- Sync: Policies with `@odata.type` of `#microsoft.graph.windowsUpdateForBusinessConfiguration` should be correctly identified and synced as `windowsUpdateRing` policies. +- Snapshot capture: Full snapshot of all settings within a Windows Update Ring policy. +- Restore: Restore a Windows Update Ring policy from a snapshot. +- UI: Display the settings of a Windows Update Ring policy in a readable, normalized format. + +- Policy type: `windowsFeatureUpdateProfile` +- Sync: Feature Update Profiles should be listed and synced from `deviceManagement/windowsFeatureUpdateProfiles`. +- Snapshot capture: Full snapshot of the Feature Update Profile payload. +- Restore: Restore a Feature Update Profile from a snapshot. +- UI: Display the key settings of a Feature Update Profile in a readable, normalized format. + +- Policy type: `windowsQualityUpdateProfile` +- Sync: Quality Update Profiles should be listed and synced from `deviceManagement/windowsQualityUpdateProfiles`. +- Snapshot capture: Full snapshot of the Quality Update Profile payload. +- Restore: Restore a Quality Update Profile from a snapshot. +- UI: Display the key settings of a Quality Update Profile in a readable, normalized format. + +## Out of Scope (v1) +- Advanced analytics or reporting on update compliance. +- Per-setting partial restore. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Inventory + readable view +As an admin, I can see my Windows Update Ring policies in the policy list and view their configured settings in a clear, understandable format. + +**Acceptance** +1. Windows Update Ring policies are listed in the main policy table with the correct type name. +2. The policy detail view shows a structured list/table of configured settings (e.g., "Quality update deferral period", "Automatic update behavior"). +3. Policy Versions store the snapshot and render the settings in the “Normalized settings” view. + +### User Story 2 — Backup/Version capture +As an admin, when I back up or create a new version of a Windows Update Ring policy, the snapshot contains all its settings. + +**Acceptance** +1. The backup/version payload in the `snapshot` column contains all the properties of the `windowsUpdateForBusinessConfiguration` object. + +### User Story 3 — Restore settings +As an admin, I can restore a Windows Update Ring policy from a backup or a previous version. + +**Acceptance** +1. The restore operation correctly applies the settings from the snapshot to the target policy in Intune. +2. The restore process is audited. + +### User Story 4 — Feature Updates inventory + readable view +As an admin, I can see my Windows Feature Update Profiles in the policy list and view their configured rollout/version settings in a clear, understandable format. + +**Acceptance** +1. Feature Update Profiles are listed in the main policy table with the correct type name. +2. The policy detail view shows a structured list/table of configured settings (e.g., feature update version, rollout window). +3. Policy Versions store the snapshot and render the settings in the “Normalized settings” view. + +### User Story 5 — Quality Updates inventory + readable view +As an admin, I can see my Windows Quality Update Profiles in the policy list and view their configured release/content settings in a clear, understandable format. + +**Acceptance** +1. Quality Update Profiles are listed in the main policy table with the correct type name. +2. The policy detail view shows a structured list/table of configured settings (e.g., release, deployable content). +3. Policy Versions store the snapshot and render the settings in the “Normalized settings” view. diff --git a/specs/012-windows-update-rings/tasks.md b/specs/012-windows-update-rings/tasks.md new file mode 100644 index 0000000..ed5d9a6 --- /dev/null +++ b/specs/012-windows-update-rings/tasks.md @@ -0,0 +1,26 @@ +# Tasks: Windows Update Rings (012) + +**Branch**: `feat/012-windows-update-rings` | **Date**: 2025-12-31 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Contracts + Snapshot Hydration +- [X] T001 Verify `config/graph_contracts.php` for `windowsUpdateRing` (resource, allowed_select, type_family, etc.). +- [X] T002 Extend `PolicySnapshotService` to hydrate `windowsUpdateForBusinessConfiguration` settings. +- [X] T001b Fix Graph filters for update rings (`isof(...)`) and add `windowsFeatureUpdateProfile` support. + +## Phase 2: Restore +- [X] T003 Implement restore apply for `windowsUpdateRing` settings in `RestoreService.php`. + +## Phase 3: UI Normalization +- [X] T004 Add `WindowsUpdateRingNormalizer` and register it (Policy “Normalized settings” is readable). +- [X] T004b Add `WindowsFeatureUpdateProfileNormalizer` and register it (Policy “Normalized settings” is readable). +- [X] T004c Add `WindowsQualityUpdateProfileNormalizer` and register it (Policy “Normalized settings” is readable). + +## Phase 4: Tests + Verification +- [X] T005 Add tests for sync filters + supported types. +- [X] T006 Add tests for restore apply. +- [X] T007 Run tests (targeted). +- [X] T008 Run Pint (`./vendor/bin/pint --dirty`). + +## Open TODOs (Follow-up) +- None yet. diff --git a/tests/Feature/BulkProgressNotificationTest.php b/tests/Feature/BulkProgressNotificationTest.php index d01960e..19395e6 100644 --- a/tests/Feature/BulkProgressNotificationTest.php +++ b/tests/Feature/BulkProgressNotificationTest.php @@ -11,6 +11,7 @@ test('progress widget shows running operations for current tenant and user', function () { $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); $user = User::factory()->create(); // Own running op @@ -39,9 +40,6 @@ 'status' => 'running', ]); - // $tenant->makeCurrent(); - $tenant->forceFill(['is_current' => true])->save(); - auth()->login($user); // Login user explicitly for auth()->id() call in component Livewire::actingAs($user) diff --git a/tests/Feature/Filament/AppProtectionPolicySettingsDisplayTest.php b/tests/Feature/Filament/AppProtectionPolicySettingsDisplayTest.php index c214e10..809b9ed 100644 --- a/tests/Feature/Filament/AppProtectionPolicySettingsDisplayTest.php +++ b/tests/Feature/Filament/AppProtectionPolicySettingsDisplayTest.php @@ -12,7 +12,7 @@ test('policy detail shows app protection settings in readable sections', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, diff --git a/tests/Feature/Filament/BackupCreationTest.php b/tests/Feature/Filament/BackupCreationTest.php index e619c15..728a913 100644 --- a/tests/Feature/Filament/BackupCreationTest.php +++ b/tests/Feature/Filament/BackupCreationTest.php @@ -73,7 +73,7 @@ public function request(string $method, string $path, array $options = []): Grap }); $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], ]); diff --git a/tests/Feature/Filament/HousekeepingTest.php b/tests/Feature/Filament/HousekeepingTest.php index 9693d8b..fb2a62e 100644 --- a/tests/Feature/Filament/HousekeepingTest.php +++ b/tests/Feature/Filament/HousekeepingTest.php @@ -23,6 +23,8 @@ 'name' => 'Tenant', ]); + $tenant->makeCurrent(); + $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => 'Set 1', @@ -60,6 +62,8 @@ 'name' => 'Tenant 2', ]); + $tenant->makeCurrent(); + $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => 'Set with restore', @@ -93,6 +97,8 @@ 'name' => 'Tenant Force', ]); + $tenant->makeCurrent(); + $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => 'Set force', @@ -132,6 +138,8 @@ 'name' => 'Tenant Restore Backup Set', ]); + $tenant->makeCurrent(); + $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => 'Set restore', @@ -171,6 +179,8 @@ 'name' => 'Tenant Restore Run', ]); + $tenant->makeCurrent(); + $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => 'Set RR', @@ -207,6 +217,8 @@ 'name' => 'Tenant Restore Restore Run', ]); + $tenant->makeCurrent(); + $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => 'Set for restore run restore', diff --git a/tests/Feature/Filament/MalformedSnapshotWarningTest.php b/tests/Feature/Filament/MalformedSnapshotWarningTest.php index 9b436b5..07bcea1 100644 --- a/tests/Feature/Filament/MalformedSnapshotWarningTest.php +++ b/tests/Feature/Filament/MalformedSnapshotWarningTest.php @@ -13,7 +13,7 @@ test('malformed snapshot renders warning on policy and version detail', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, diff --git a/tests/Feature/Filament/ODataTypeMismatchTest.php b/tests/Feature/Filament/ODataTypeMismatchTest.php index 0487693..3aca519 100644 --- a/tests/Feature/Filament/ODataTypeMismatchTest.php +++ b/tests/Feature/Filament/ODataTypeMismatchTest.php @@ -50,7 +50,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon }); $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, diff --git a/tests/Feature/Filament/PolicyListingTest.php b/tests/Feature/Filament/PolicyListingTest.php index 6099fcb..72ba98f 100644 --- a/tests/Feature/Filament/PolicyListingTest.php +++ b/tests/Feature/Filament/PolicyListingTest.php @@ -8,7 +8,7 @@ test('policies are listed for the active tenant', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], ]); diff --git a/tests/Feature/Filament/PolicySettingsDisplayTest.php b/tests/Feature/Filament/PolicySettingsDisplayTest.php index 8005eaa..56acd96 100644 --- a/tests/Feature/Filament/PolicySettingsDisplayTest.php +++ b/tests/Feature/Filament/PolicySettingsDisplayTest.php @@ -12,13 +12,12 @@ test('policy detail shows normalized settings section', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ diff --git a/tests/Feature/Filament/PolicyVersionReadableLayoutTest.php b/tests/Feature/Filament/PolicyVersionReadableLayoutTest.php index c9181c2..e53e7ab 100644 --- a/tests/Feature/Filament/PolicyVersionReadableLayoutTest.php +++ b/tests/Feature/Filament/PolicyVersionReadableLayoutTest.php @@ -12,13 +12,12 @@ test('policy version detail renders tabs and scroll-safe blocks', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ diff --git a/tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php b/tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php index 6a9d3e3..63a3dce 100644 --- a/tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php +++ b/tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php @@ -12,13 +12,12 @@ test('policy version view shows scope tags even when assignments are missing', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ diff --git a/tests/Feature/Filament/PolicyVersionSettingsTest.php b/tests/Feature/Filament/PolicyVersionSettingsTest.php index 2f25d7d..56af173 100644 --- a/tests/Feature/Filament/PolicyVersionSettingsTest.php +++ b/tests/Feature/Filament/PolicyVersionSettingsTest.php @@ -12,13 +12,12 @@ test('policy version detail shows raw and normalized settings', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ diff --git a/tests/Feature/Filament/PolicyVersionTest.php b/tests/Feature/Filament/PolicyVersionTest.php index 55aed75..ed14f79 100644 --- a/tests/Feature/Filament/PolicyVersionTest.php +++ b/tests/Feature/Filament/PolicyVersionTest.php @@ -11,11 +11,13 @@ test('policy versions render with timeline data', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], ]); + $tenant->makeCurrent(); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'policy-1', diff --git a/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php b/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php index 45ec5a2..49b7cbd 100644 --- a/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php +++ b/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php @@ -13,13 +13,12 @@ it('shows Settings tab for Settings Catalog policy', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ @@ -86,13 +85,12 @@ it('shows display names instead of definition IDs', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ @@ -143,13 +141,12 @@ it('shows fallback prettified labels when definitions not cached', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ @@ -195,13 +192,12 @@ it('shows tabbed layout for non-Settings Catalog policies', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ @@ -242,7 +238,7 @@ // T034: Test display names shown (not definition IDs) it('displays setting display names instead of raw definition IDs', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', 'is_current' => true, ]); @@ -296,7 +292,7 @@ // T035: Test values formatted correctly it('formats setting values correctly based on type', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', 'is_current' => true, ]); @@ -370,7 +366,7 @@ // T036: Test search/filter functionality it('search filters settings in real-time', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', 'is_current' => true, ]); @@ -433,7 +429,7 @@ // T037: Test graceful degradation for missing definitions it('shows prettified fallback labels when definitions are not cached', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', 'is_current' => true, ]); diff --git a/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php b/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php index fe896c0..2505927 100644 --- a/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php +++ b/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php @@ -13,13 +13,12 @@ test('settings catalog policies render a normalized settings table', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ diff --git a/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php b/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php index 7f38369..f34e7c3 100644 --- a/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php +++ b/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php @@ -75,16 +75,11 @@ public function request(string $method, string $path, array $options = []): Grap app()->instance(GraphClientInterface::class, new SettingsCatalogFakeGraphClient($responses)); $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, ]); - - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); - $_ENV['INTUNE_TENANT_ID'] = $tenant->tenant_id; - $_SERVER['INTUNE_TENANT_ID'] = $tenant->tenant_id; - $tenant->makeCurrent(); expect(Tenant::current()->id)->toBe($tenant->id); diff --git a/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php b/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php index 9ee2573..ddc67fb 100644 --- a/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php +++ b/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php @@ -13,13 +13,12 @@ test('settings catalog settings render as a filament table with details action', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ diff --git a/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php b/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php new file mode 100644 index 0000000..fcbf824 --- /dev/null +++ b/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php @@ -0,0 +1,213 @@ + + */ + public array $applyPolicyCalls = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, ['payload' => []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->applyPolicyCalls[] = [ + 'policyType' => $policyType, + 'policyId' => $policyId, + 'payload' => $payload, + 'options' => $options, + ]; + + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } +} + +test('restore execution applies windows feature update profile with sanitized payload', function () { + $client = new WindowsUpdateProfilesRestoreGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-feature', + 'policy_type' => 'windowsFeatureUpdateProfile', + 'display_name' => 'Feature Updates A', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupPayload = [ + 'id' => 'policy-feature', + '@odata.type' => '#microsoft.graph.windowsFeatureUpdateProfile', + 'displayName' => 'Feature Updates A', + 'featureUpdateVersion' => 'Windows 11, version 23H2', + 'deployableContentDisplayName' => 'Some Content', + 'endOfSupportDate' => '2026-01-01T00:00:00Z', + 'roleScopeTagIds' => ['0'], + ]; + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => $backupPayload, + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + ); + + expect($run->status)->toBe('completed'); + expect($run->results[0]['status'])->toBe('applied'); + + expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1); + + expect($client->applyPolicyCalls)->toHaveCount(1); + expect($client->applyPolicyCalls[0]['policyType'])->toBe('windowsFeatureUpdateProfile'); + expect($client->applyPolicyCalls[0]['policyId'])->toBe('policy-feature'); + expect($client->applyPolicyCalls[0]['options']['method'] ?? null)->toBe('PATCH'); + + expect($client->applyPolicyCalls[0]['payload']['featureUpdateVersion'])->toBe('Windows 11, version 23H2'); + expect($client->applyPolicyCalls[0]['payload']['roleScopeTagIds'])->toBe(['0']); + + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('id'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('@odata.type'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('deployableContentDisplayName'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('endOfSupportDate'); +}); + +test('restore execution applies windows quality update profile with sanitized payload', function () { + $client = new WindowsUpdateProfilesRestoreGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-quality', + 'policy_type' => 'windowsQualityUpdateProfile', + 'display_name' => 'Quality Updates A', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupPayload = [ + 'id' => 'policy-quality', + '@odata.type' => '#microsoft.graph.windowsQualityUpdateProfile', + 'displayName' => 'Quality Updates A', + 'qualityUpdateCveIds' => ['CVE-2025-0001'], + 'deployableContentDisplayName' => 'Some Content', + 'releaseDateDisplayName' => 'January 2026', + 'roleScopeTagIds' => ['0'], + ]; + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => $backupPayload, + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + ); + + expect($run->status)->toBe('completed'); + expect($run->results[0]['status'])->toBe('applied'); + + expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1); + + expect($client->applyPolicyCalls)->toHaveCount(1); + expect($client->applyPolicyCalls[0]['policyType'])->toBe('windowsQualityUpdateProfile'); + expect($client->applyPolicyCalls[0]['policyId'])->toBe('policy-quality'); + expect($client->applyPolicyCalls[0]['options']['method'] ?? null)->toBe('PATCH'); + + expect($client->applyPolicyCalls[0]['payload']['qualityUpdateCveIds'])->toBe(['CVE-2025-0001']); + expect($client->applyPolicyCalls[0]['payload']['roleScopeTagIds'])->toBe(['0']); + + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('id'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('@odata.type'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('deployableContentDisplayName'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('releaseDateDisplayName'); +}); diff --git a/tests/Feature/Filament/WindowsUpdateRingPolicyTest.php b/tests/Feature/Filament/WindowsUpdateRingPolicyTest.php new file mode 100644 index 0000000..fccc528 --- /dev/null +++ b/tests/Feature/Filament/WindowsUpdateRingPolicyTest.php @@ -0,0 +1,77 @@ + 'local-tenant', + 'name' => 'Tenant One', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-wuring', + 'policy_type' => 'windowsUpdateRing', + 'display_name' => 'Windows Update Ring A', + 'platform' => 'windows', + ]); + + PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'created_by' => 'tester@example.com', + 'captured_at' => CarbonImmutable::now(), + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', + 'automaticUpdateMode' => 'autoInstallAtMaintenanceTime', + 'featureUpdatesDeferralPeriodInDays' => 14, + 'deadlineForFeatureUpdatesInDays' => 7, + 'deliveryOptimizationMode' => 'httpWithPeeringNat', + 'qualityUpdatesPaused' => false, + 'userPauseAccess' => 'allow', + ], + ]); + + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->get(PolicyResource::getUrl('view', ['record' => $policy])); + + $response->assertOk(); + + // Check for correct titles and settings from the normalizer + $response->assertSee('Update Settings'); + $response->assertSee('Automatic Update Mode'); + $response->assertSee('autoInstallAtMaintenanceTime'); + $response->assertSee('Feature Updates Deferral Period In Days'); + $response->assertSee('14'); + $response->assertSee('Quality Updates Paused'); + $response->assertSee('No'); + + $response->assertSee('User Experience'); + $response->assertSee('Deadline For Feature Updates In Days'); + $response->assertSee('7'); + $response->assertSee('User Pause Access'); + $response->assertSee('allow'); + + $response->assertSee('Advanced Options'); + $response->assertSee('Delivery Optimization Mode'); + $response->assertSee('httpWithPeeringNat'); + + // $response->assertDontSee('@odata.type'); +}); diff --git a/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php b/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php new file mode 100644 index 0000000..441dc24 --- /dev/null +++ b/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php @@ -0,0 +1,151 @@ + []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->applied[] = [ + 'policyType' => $policyType, + 'policyId' => $policyId, + 'payload' => $payload, + 'options' => $options, + ]; + + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requests[] = [ + 'method' => strtoupper($method), + 'path' => $path, + 'payload' => $options['json'] ?? null, + ]; + + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }; + + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-wuring', + 'policy_type' => 'windowsUpdateRing', + 'display_name' => 'Windows Update Ring A', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupPayload = [ + 'id' => 'policy-wuring', + '@odata.type' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', + 'automaticUpdateMode' => 'autoInstallAtMaintenanceTime', + 'featureUpdatesDeferralPeriodInDays' => 14, + 'version' => 7, + 'qualityUpdatesPauseStartDate' => '2025-01-01T00:00:00Z', + 'featureUpdatesPauseStartDate' => '2025-01-02T00:00:00Z', + 'qualityUpdatesWillBeRolledBack' => false, + 'featureUpdatesWillBeRolledBack' => false, + 'roleScopeTagIds' => ['0'], + ]; + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => $backupPayload, + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + ); + + expect($run->status)->toBe('completed'); + expect($run->results[0]['status'])->toBe('applied'); + + $this->assertDatabaseHas('audit_logs', [ + 'action' => 'restore.executed', + 'resource_id' => (string) $run->id, + ]); + + expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1); + + expect($client->requests)->toHaveCount(1); + expect($client->requests[0]['method'])->toBe('PATCH'); + expect($client->requests[0]['path'])->toBe('deviceManagement/deviceConfigurations/policy-wuring/microsoft.graph.windowsUpdateForBusinessConfiguration'); + + expect($client->requests[0]['payload']['automaticUpdateMode'])->toBe('autoInstallAtMaintenanceTime'); + expect($client->requests[0]['payload']['featureUpdatesDeferralPeriodInDays'])->toBe(14); + expect($client->requests[0]['payload']['roleScopeTagIds'])->toBe(['0']); + + expect($client->requests[0]['payload'])->not->toHaveKey('id'); + expect($client->requests[0]['payload'])->not->toHaveKey('@odata.type'); + expect($client->requests[0]['payload'])->not->toHaveKey('version'); + expect($client->requests[0]['payload'])->not->toHaveKey('qualityUpdatesPauseStartDate'); + expect($client->requests[0]['payload'])->not->toHaveKey('featureUpdatesPauseStartDate'); + expect($client->requests[0]['payload'])->not->toHaveKey('qualityUpdatesWillBeRolledBack'); + expect($client->requests[0]['payload'])->not->toHaveKey('featureUpdatesWillBeRolledBack'); +}); diff --git a/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php b/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php index 8b4a56f..fb3dcb1 100644 --- a/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php +++ b/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php @@ -49,7 +49,7 @@ public function request(string $method, string $path, array $options = []): Grap test('sync skips managed app configurations from app protection inventory', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant'), + 'tenant_id' => 'test-tenant', 'name' => 'Test Tenant', 'metadata' => [], 'is_current' => true, diff --git a/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php b/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php index d6a50ab..4ae8c1f 100644 --- a/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php +++ b/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php @@ -49,7 +49,7 @@ public function request(string $method, string $path, array $options = []): Grap test('sync revives ignored policies when they exist in Intune', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant'), + 'tenant_id' => 'test-tenant', 'name' => 'Test Tenant', 'metadata' => [], 'is_current' => true, @@ -94,7 +94,7 @@ public function request(string $method, string $path, array $options = []): Grap test('sync creates new policies even if ignored ones exist with same external_id', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant-2'), + 'tenant_id' => 'test-tenant-2', 'name' => 'Test Tenant 2', 'metadata' => [], 'is_current' => true, diff --git a/tests/Feature/PolicySyncServiceTest.php b/tests/Feature/PolicySyncServiceTest.php new file mode 100644 index 0000000..ef6d674 --- /dev/null +++ b/tests/Feature/PolicySyncServiceTest.php @@ -0,0 +1,77 @@ +create([ + 'status' => 'active', + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-1', + 'policy_type' => 'appProtectionPolicy', + 'ignored_at' => null, + ]); + + $logger = mock(GraphLogger::class); + + $logger->shouldReceive('logRequest') + ->zeroOrMoreTimes() + ->andReturnNull(); + + $logger->shouldReceive('logResponse') + ->zeroOrMoreTimes() + ->andReturnNull(); + + mock(GraphClientInterface::class) + ->shouldReceive('listPolicies') + ->once() + ->andReturn(new GraphResponse( + success: true, + data: [ + [ + 'id' => 'policy-1', + 'displayName' => 'Ignored policy', + '@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration', + ], + ], + )); + + $service = app(PolicySyncService::class); + + $synced = $service->syncPolicies($tenant, [ + ['type' => 'appProtectionPolicy'], + ]); + + $policy->refresh(); + + expect($policy->ignored_at)->not->toBeNull(); + expect($synced)->toBeArray()->toBeEmpty(); +}); + +it('uses isof filters for windows update rings and supports feature/quality update profiles', function () { + $supported = config('tenantpilot.supported_policy_types'); + $byType = collect($supported)->keyBy('type'); + + expect($byType)->toHaveKeys(['deviceConfiguration', 'windowsUpdateRing', 'windowsFeatureUpdateProfile', 'windowsQualityUpdateProfile']); + + expect($byType['deviceConfiguration']['filter'] ?? null) + ->toBe("not isof('microsoft.graph.windowsUpdateForBusinessConfiguration')"); + + expect($byType['windowsUpdateRing']['filter'] ?? null) + ->toBe("isof('microsoft.graph.windowsUpdateForBusinessConfiguration')"); + + expect($byType['windowsFeatureUpdateProfile']['endpoint'] ?? null) + ->toBe('deviceManagement/windowsFeatureUpdateProfiles'); + + expect($byType['windowsQualityUpdateProfile']['endpoint'] ?? null) + ->toBe('deviceManagement/windowsQualityUpdateProfiles'); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index ee63ad0..808b72f 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,4 +4,13 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase; -abstract class TestCase extends BaseTestCase {} +abstract class TestCase extends BaseTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + putenv('INTUNE_TENANT_ID'); + unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']); + } +} diff --git a/tests/Unit/FoundationSnapshotServiceTest.php b/tests/Unit/FoundationSnapshotServiceTest.php index bcd1f8d..e9c4c84 100644 --- a/tests/Unit/FoundationSnapshotServiceTest.php +++ b/tests/Unit/FoundationSnapshotServiceTest.php @@ -115,7 +115,7 @@ public function request(string $method, string $path, array $options = []): Grap expect($result['items'][1]['source_id'])->toBe('filter-2'); expect($client->requests[0]['path'])->toBe('deviceManagement/assignmentFilters'); - expect($client->requests[0]['options']['query']['$select'])->toBe(['id', 'displayName']); + expect($client->requests[0]['options']['query']['$select'])->toBe('id,displayName'); expect($client->requests[1]['path'])->toBe('deviceManagement/assignmentFilters?$skiptoken=abc'); expect($client->requests[1]['options']['query'])->toBe([]); }); diff --git a/tests/Unit/PolicySnapshotServiceTest.php b/tests/Unit/PolicySnapshotServiceTest.php index 9fa44ae..2bea6c1 100644 --- a/tests/Unit/PolicySnapshotServiceTest.php +++ b/tests/Unit/PolicySnapshotServiceTest.php @@ -169,3 +169,85 @@ public function request(string $method, string $path, array $options = []): Grap expect($client->requests[0][3]['select'])->toContain('roleScopeTagIds'); expect($client->requests[0][3]['select'])->not->toContain('@odata.type'); }); + +class WindowsUpdateRingSnapshotGraphClient 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, + '@odata.type' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', + 'displayName' => 'Ring A', + ], + ]); + } + + 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]; + + if ($method === 'GET' && $path === 'deviceManagement/deviceConfigurations/policy-wuring/microsoft.graph.windowsUpdateForBusinessConfiguration') { + return new GraphResponse(success: true, data: [ + 'automaticUpdateMode' => 'autoInstallAtMaintenanceTime', + 'featureUpdatesDeferralPeriodInDays' => 14, + ]); + } + + return new GraphResponse(success: true, data: []); + } +} + +it('hydrates windows update ring snapshots via derived type cast endpoint', function () { + $client = new WindowsUpdateRingSnapshotGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-wuring', + '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' => 'policy-wuring', + 'policy_type' => 'windowsUpdateRing', + 'display_name' => 'Ring A', + 'platform' => 'windows', + ]); + + $service = app(PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('payload'); + expect($result['payload']['@odata.type'])->toBe('#microsoft.graph.windowsUpdateForBusinessConfiguration'); + expect($result['payload']['automaticUpdateMode'])->toBe('autoInstallAtMaintenanceTime'); + expect($result['payload']['featureUpdatesDeferralPeriodInDays'])->toBe(14); + expect($result['metadata']['properties_hydration'] ?? null)->toBe('complete'); +}); diff --git a/tests/Unit/TenantCurrentTest.php b/tests/Unit/TenantCurrentTest.php index 462d952..86687c1 100644 --- a/tests/Unit/TenantCurrentTest.php +++ b/tests/Unit/TenantCurrentTest.php @@ -108,3 +108,27 @@ function restoreIntuneTenantId(string|false $original): void restoreIntuneTenantId($originalEnv); }); + +it('makeCurrent keeps tenant current when already current', function () { + $originalEnv = getenv('INTUNE_TENANT_ID'); + putenv('INTUNE_TENANT_ID='); + + $current = Tenant::create([ + 'tenant_id' => 'tenant-current', + 'name' => 'Already Current', + 'is_current' => true, + ]); + + $other = Tenant::create([ + 'tenant_id' => 'tenant-other', + 'name' => 'Other Tenant', + 'is_current' => false, + ]); + + $current->makeCurrent(); + + expect($current->fresh()->is_current)->toBeTrue(); + expect($other->fresh()->is_current)->toBeFalse(); + + restoreIntuneTenantId($originalEnv); +});