From a7d715c89e35a22f655326a0d4575a3e8d93efec Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 4 Jan 2026 00:37:10 +0100 Subject: [PATCH] feat(018): add windows driver update profiles --- .specify/spec.md | 9 ++ app/Providers/AppServiceProvider.php | 2 + .../WindowsDriverUpdateProfileNormalizer.php | 125 ++++++++++++++++++ config/graph_contracts.php | 36 +++++ config/tenantpilot.php | 10 ++ .../checklists/requirements.md | 13 +- specs/018-driver-updates-wufb/spec.md | 8 +- specs/018-driver-updates-wufb/tasks.md | 26 ++-- .../WindowsUpdateProfilesRestoreTest.php | 85 ++++++++++++ tests/Feature/PolicySyncServiceTest.php | 46 ++++++- ...ndowsDriverUpdateProfileNormalizerTest.php | 38 ++++++ 11 files changed, 374 insertions(+), 24 deletions(-) create mode 100644 app/Services/Intune/WindowsDriverUpdateProfileNormalizer.php create mode 100644 tests/Unit/WindowsDriverUpdateProfileNormalizerTest.php diff --git a/.specify/spec.md b/.specify/spec.md index e664e78..227841a 100644 --- a/.specify/spec.md +++ b/.specify/spec.md @@ -42,6 +42,10 @@ ## Scope name: "Quality Updates (Windows)" graph_resource: "deviceManagement/windowsQualityUpdateProfiles" + - key: windowsDriverUpdateProfile + name: "Driver Updates (Windows)" + graph_resource: "deviceManagement/windowsDriverUpdateProfiles" + - key: deviceCompliancePolicy name: "Device Compliance" graph_resource: "deviceManagement/deviceCompliancePolicies" @@ -158,6 +162,11 @@ ## Scope restore: enabled risk: high + windowsDriverUpdateProfile: + backup: full + restore: enabled + risk: high + deviceCompliancePolicy: backup: full restore: enabled diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2bb90ea..b5c7c32 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -13,6 +13,7 @@ use App\Services\Intune\ManagedDeviceAppConfigurationNormalizer; use App\Services\Intune\ScriptsPolicyNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer; +use App\Services\Intune\WindowsDriverUpdateProfileNormalizer; use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer; use App\Services\Intune\WindowsQualityUpdateProfileNormalizer; use App\Services\Intune\WindowsUpdateRingNormalizer; @@ -49,6 +50,7 @@ public function register(): void ManagedDeviceAppConfigurationNormalizer::class, ScriptsPolicyNormalizer::class, SettingsCatalogPolicyNormalizer::class, + WindowsDriverUpdateProfileNormalizer::class, WindowsFeatureUpdateProfileNormalizer::class, WindowsQualityUpdateProfileNormalizer::class, WindowsUpdateRingNormalizer::class, diff --git a/app/Services/Intune/WindowsDriverUpdateProfileNormalizer.php b/app/Services/Intune/WindowsDriverUpdateProfileNormalizer.php new file mode 100644 index 0000000..0bd657e --- /dev/null +++ b/app/Services/Intune/WindowsDriverUpdateProfileNormalizer.php @@ -0,0 +1,125 @@ +>, 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->buildDriverUpdateBlock($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 buildDriverUpdateBlock(array $snapshot): ?array + { + $entries = []; + + $displayName = Arr::get($snapshot, 'displayName'); + + if (is_string($displayName) && $displayName !== '') { + $entries[] = ['key' => 'Name', 'value' => $displayName]; + } + + $approvalType = Arr::get($snapshot, 'approvalType'); + + if (is_string($approvalType) && $approvalType !== '') { + $entries[] = ['key' => 'Approval type', 'value' => $approvalType]; + } + + $deferral = Arr::get($snapshot, 'deploymentDeferralInDays'); + + if (is_int($deferral) || (is_numeric($deferral) && (string) (int) $deferral === (string) $deferral)) { + $entries[] = ['key' => 'Deployment deferral (days)', 'value' => (int) $deferral]; + } + + $deviceReporting = Arr::get($snapshot, 'deviceReporting'); + + if (is_int($deviceReporting) || (is_numeric($deviceReporting) && (string) (int) $deviceReporting === (string) $deviceReporting)) { + $entries[] = ['key' => 'Devices reporting', 'value' => (int) $deviceReporting]; + } + + $newUpdates = Arr::get($snapshot, 'newUpdates'); + + if (is_int($newUpdates) || (is_numeric($newUpdates) && (string) (int) $newUpdates === (string) $newUpdates)) { + $entries[] = ['key' => 'New driver updates', 'value' => (int) $newUpdates]; + } + + $inventorySyncStatus = Arr::get($snapshot, 'inventorySyncStatus'); + + if (is_array($inventorySyncStatus)) { + $state = Arr::get($inventorySyncStatus, 'driverInventorySyncState'); + + if (is_string($state) && $state !== '') { + $entries[] = ['key' => 'Inventory sync state', 'value' => $state]; + } + + $lastSuccessful = $this->formatDateTime(Arr::get($inventorySyncStatus, 'lastSuccessfulSyncDateTime')); + + if ($lastSuccessful !== null) { + $entries[] = ['key' => 'Last successful inventory sync', 'value' => $lastSuccessful]; + } + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Driver 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/config/graph_contracts.php b/config/graph_contracts.php index b6c35a4..674e824 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -296,6 +296,42 @@ 'assignments_delete_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assignments/{assignmentId}', 'assignments_delete_method' => 'DELETE', ], + 'windowsDriverUpdateProfile' => [ + 'resource' => 'deviceManagement/windowsDriverUpdateProfiles', + 'allowed_select' => [ + 'id', + 'displayName', + 'description', + '@odata.type', + 'createdDateTime', + 'lastModifiedDateTime', + 'approvalType', + 'deploymentDeferralInDays', + 'roleScopeTagIds', + ], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.windowsDriverUpdateProfile', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'update_strip_keys' => [ + 'deviceReporting', + 'newUpdates', + 'inventorySyncStatus', + ], + 'assignments_list_path' => '/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/windowsDriverUpdateProfiles/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + 'supports_scope_tags' => true, + 'scope_tag_field' => 'roleScopeTagIds', + ], 'deviceCompliancePolicy' => [ 'resource' => 'deviceManagement/deviceCompliancePolicies', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], diff --git a/config/tenantpilot.php b/config/tenantpilot.php index 1f6f205..d222293 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -64,6 +64,16 @@ 'restore' => 'enabled', 'risk' => 'high', ], + [ + 'type' => 'windowsDriverUpdateProfile', + 'label' => 'Driver Updates (Windows)', + 'category' => 'Update Management', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/windowsDriverUpdateProfiles', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'high', + ], [ 'type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance', diff --git a/specs/018-driver-updates-wufb/checklists/requirements.md b/specs/018-driver-updates-wufb/checklists/requirements.md index 2186975..1a0204c 100644 --- a/specs/018-driver-updates-wufb/checklists/requirements.md +++ b/specs/018-driver-updates-wufb/checklists/requirements.md @@ -3,13 +3,12 @@ # Requirements Checklist (018) **Created**: 2026-01-03 **Feature**: [spec.md](../spec.md) -- [ ] `windowsDriverUpdateProfile` is added to `config/tenantpilot.php` (metadata, endpoint, backup/restore mode, risk). -- [ ] Graph contract exists in `config/graph_contracts.php` (resource, type family, create/update methods, assignments paths). -- [ ] Sync lists and stores driver update profiles in the Policies inventory. +- [x] `windowsDriverUpdateProfile` is added to `config/tenantpilot.php` (metadata, endpoint, backup/restore mode, risk). +- [x] Graph contract exists in `config/graph_contracts.php` (resource, type family, create/update methods, assignments paths). +- [x] Sync lists and stores driver update profiles in the Policies inventory. - [ ] Snapshot capture stores a complete payload for backups and versions. - [ ] Restore preview is available and respects the configured restore mode. -- [ ] Restore execution applies only patchable properties and records audit logs. -- [ ] Normalized settings view is readable for admins (no raw-only UX). +- [x] Restore execution applies only patchable properties and records audit logs. +- [x] Normalized settings view is readable for admins (no raw-only UX). - [ ] Pest tests cover sync + snapshot + restore + normalized display. -- [ ] Pint run (`./vendor/bin/pint --dirty`) on touched files. - +- [x] Pint run (`./vendor/bin/pint --dirty`) on touched files. diff --git a/specs/018-driver-updates-wufb/spec.md b/specs/018-driver-updates-wufb/spec.md index d5315e0..5ff4aaa 100644 --- a/specs/018-driver-updates-wufb/spec.md +++ b/specs/018-driver-updates-wufb/spec.md @@ -27,12 +27,15 @@ ## Out of Scope (v1) - Advanced reporting on driver compliance. - Partial per-setting restore. -## Graph API Assumptions (to verify) +## Graph API Details (confirmed) - **Resource**: `deviceManagement/windowsDriverUpdateProfiles` - **@odata.type**: `#microsoft.graph.windowsDriverUpdateProfile` -- **Assignments**: standard pattern with: +- **Patchable fields**: `displayName`, `description`, `approvalType`, `deploymentDeferralInDays`, `roleScopeTagIds` +- **Read-only fields (strip on PATCH)**: `deviceReporting`, `newUpdates`, `inventorySyncStatus`, `createdDateTime`, `lastModifiedDateTime` +- **Assignments**: - list: `/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments` - assign action: `/deviceManagement/windowsDriverUpdateProfiles/{id}/assign` + - update/delete: `/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments/{assignmentId}` ## User Scenarios & Testing @@ -74,4 +77,3 @@ ### Non-Functional Requirements - **NFR-001**: Preserve tenant isolation and least privilege. - **NFR-002**: Keep restore safe-by-default (preview/confirmation/audit). - **NFR-003**: No new external services or dependencies. - diff --git a/specs/018-driver-updates-wufb/tasks.md b/specs/018-driver-updates-wufb/tasks.md index b6bcc69..f7b8e38 100644 --- a/specs/018-driver-updates-wufb/tasks.md +++ b/specs/018-driver-updates-wufb/tasks.md @@ -8,25 +8,25 @@ ## Phase 1: Setup - [x] T001 Create/confirm spec, plan, tasks, checklist. ## Phase 2: Research & Design -- [ ] T002 Verify Graph resource + `@odata.type` for driver update profiles. -- [ ] T003 Verify PATCHable fields and define `update_strip_keys` / `update_whitelist`. -- [ ] T004 Verify assignment endpoints (`/assignments`, `/assign`) for this resource. -- [ ] T005 Decide restore mode (`enabled` vs `preview-only`) based on risk + patchability. +- [x] T002 Verify Graph resource + `@odata.type` for driver update profiles. +- [x] T003 Verify PATCHable fields and define `update_strip_keys` / `update_whitelist`. +- [x] T004 Verify assignment endpoints (`/assignments`, `/assign`) for this resource. +- [x] T005 Decide restore mode (`enabled` vs `preview-only`) based on risk + patchability. ## Phase 3: Tests (TDD) -- [ ] T006 Add sync test ensuring `windowsDriverUpdateProfile` policies are imported and typed correctly. +- [x] T006 Add sync test ensuring `windowsDriverUpdateProfile` policies are imported and typed correctly. - [ ] T007 Add snapshot/version capture test asserting full payload is stored. - [ ] T008 Add restore preview test for this type (entries + restore_mode shown). -- [ ] T009 Add restore execution test asserting only patchable properties are sent and failures are reported with Graph metadata. -- [ ] T010 Add normalized display test for key fields. +- [x] T009 Add restore execution test asserting only patchable properties are sent and failures are reported with Graph metadata. +- [x] T010 Add normalized display test for key fields. ## Phase 4: Implementation -- [ ] T011 Add `windowsDriverUpdateProfile` to `config/tenantpilot.php`. -- [ ] T012 Add Graph contract entry in `config/graph_contracts.php`. +- [x] T011 Add `windowsDriverUpdateProfile` to `config/tenantpilot.php`. +- [x] T012 Add Graph contract entry in `config/graph_contracts.php`. - [ ] T013 Implement any required snapshot hydration (if Graph uses subresources). -- [ ] T014 Implement restore apply support in `RestoreService` (contract-driven sanitization). -- [ ] T015 Add a `WindowsDriverUpdateProfileNormalizer` and register it. +- [x] T014 Implement restore apply support in `RestoreService` (contract-driven sanitization). +- [x] T015 Add a `WindowsDriverUpdateProfileNormalizer` and register it. ## Phase 5: Verification -- [ ] T016 Run targeted tests. -- [ ] T017 Run Pint (`./vendor/bin/pint --dirty`). +- [x] T016 Run targeted tests. +- [x] T017 Run Pint (`./vendor/bin/pint --dirty`). diff --git a/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php b/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php index fcbf824..4ef31ec 100644 --- a/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php +++ b/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php @@ -211,3 +211,88 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('deployableContentDisplayName'); expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('releaseDateDisplayName'); }); + +test('restore execution applies windows driver 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-driver', + 'policy_type' => 'windowsDriverUpdateProfile', + 'display_name' => 'Driver Updates A', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupPayload = [ + 'id' => 'policy-driver', + '@odata.type' => '#microsoft.graph.windowsDriverUpdateProfile', + 'displayName' => 'Driver Updates A', + 'description' => 'Drivers rollout policy', + 'approvalType' => 'automatic', + 'deploymentDeferralInDays' => 7, + 'deviceReporting' => 12, + 'newUpdates' => 3, + 'inventorySyncStatus' => [ + 'driverInventorySyncState' => 'success', + 'lastSuccessfulSyncDateTime' => '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('windowsDriverUpdateProfile'); + expect($client->applyPolicyCalls[0]['policyId'])->toBe('policy-driver'); + expect($client->applyPolicyCalls[0]['options']['method'] ?? null)->toBe('PATCH'); + + expect($client->applyPolicyCalls[0]['payload']['approvalType'])->toBe('automatic'); + expect($client->applyPolicyCalls[0]['payload']['deploymentDeferralInDays'])->toBe(7); + 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('deviceReporting'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('newUpdates'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('inventorySyncStatus'); +}); diff --git a/tests/Feature/PolicySyncServiceTest.php b/tests/Feature/PolicySyncServiceTest.php index 60beb81..7c056a3 100644 --- a/tests/Feature/PolicySyncServiceTest.php +++ b/tests/Feature/PolicySyncServiceTest.php @@ -62,7 +62,7 @@ $supported = config('tenantpilot.supported_policy_types'); $byType = collect($supported)->keyBy('type'); - expect($byType)->toHaveKeys(['deviceConfiguration', 'windowsUpdateRing', 'windowsFeatureUpdateProfile', 'windowsQualityUpdateProfile']); + expect($byType)->toHaveKeys(['deviceConfiguration', 'windowsUpdateRing', 'windowsFeatureUpdateProfile', 'windowsQualityUpdateProfile', 'windowsDriverUpdateProfile']); expect($byType['deviceConfiguration']['filter'] ?? null) ->toBe("not isof('microsoft.graph.windowsUpdateForBusinessConfiguration')"); @@ -75,6 +75,50 @@ expect($byType['windowsQualityUpdateProfile']['endpoint'] ?? null) ->toBe('deviceManagement/windowsQualityUpdateProfiles'); + + expect($byType['windowsDriverUpdateProfile']['endpoint'] ?? null) + ->toBe('deviceManagement/windowsDriverUpdateProfiles'); +}); + +it('syncs windows driver update profiles 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('windowsDriverUpdateProfile', mockery::type('array')) + ->andReturn(new GraphResponse( + success: true, + data: [ + [ + 'id' => 'wdp-1', + 'displayName' => 'Driver Updates A', + '@odata.type' => '#microsoft.graph.windowsDriverUpdateProfile', + 'approvalType' => 'automatic', + ], + ], + )); + + $service = app(PolicySyncService::class); + + $service->syncPolicies($tenant, [ + ['type' => 'windowsDriverUpdateProfile', 'platform' => 'windows'], + ]); + + expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'windowsDriverUpdateProfile')->count()) + ->toBe(1); }); it('includes managed device app configurations in supported types', function () { diff --git a/tests/Unit/WindowsDriverUpdateProfileNormalizerTest.php b/tests/Unit/WindowsDriverUpdateProfileNormalizerTest.php new file mode 100644 index 0000000..226e594 --- /dev/null +++ b/tests/Unit/WindowsDriverUpdateProfileNormalizerTest.php @@ -0,0 +1,38 @@ + '#microsoft.graph.windowsDriverUpdateProfile', + 'displayName' => 'Driver Updates A', + 'description' => 'Drivers rollout policy', + 'approvalType' => 'automatic', + 'deploymentDeferralInDays' => 7, + 'deviceReporting' => 12, + 'newUpdates' => 3, + 'inventorySyncStatus' => [ + 'driverInventorySyncState' => 'success', + 'lastSuccessfulSyncDateTime' => '2026-01-01T00:00:00Z', + ], + ]; + + $result = $normalizer->normalize($snapshot, 'windowsDriverUpdateProfile', 'windows'); + + expect($result['status'])->toBe('success'); + expect($result['settings'])->toBeArray()->not->toBeEmpty(); + + $driverBlock = collect($result['settings']) + ->first(fn (array $block) => ($block['title'] ?? null) === 'Driver Update Profile'); + + expect($driverBlock)->not->toBeNull(); + + $keys = collect($driverBlock['entries'] ?? [])->pluck('key')->all(); + + expect($keys)->toContain('Approval type', 'Deployment deferral (days)', 'Devices reporting', 'New driver updates'); +});