feat/012-windows-update-rings #18

Merged
ahmido merged 24 commits from feat/012-windows-update-rings into dev 2026-01-01 10:44:18 +00:00
39 changed files with 887 additions and 64 deletions
Showing only changes of commit 074a65669b - Show all commits

View File

@ -104,13 +104,17 @@ public function makeCurrent(): void
DB::transaction(function () { DB::transaction(function () {
static::activeQuery()->update(['is_current' => false]); 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 public static function current(): self
{ {
$envTenantId = env('INTUNE_TENANT_ID') ?: null; $envTenantId = getenv('INTUNE_TENANT_ID') ?: null;
if ($envTenantId) { if ($envTenantId) {
$tenant = static::activeQuery() $tenant = static::activeQuery()

View File

@ -10,6 +10,8 @@
use App\Services\Intune\DeviceConfigurationPolicyNormalizer; use App\Services\Intune\DeviceConfigurationPolicyNormalizer;
use App\Services\Intune\GroupPolicyConfigurationNormalizer; use App\Services\Intune\GroupPolicyConfigurationNormalizer;
use App\Services\Intune\SettingsCatalogPolicyNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer;
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
use App\Services\Intune\WindowsUpdateRingNormalizer; use App\Services\Intune\WindowsUpdateRingNormalizer;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@ -41,6 +43,8 @@ public function register(): void
DeviceConfigurationPolicyNormalizer::class, DeviceConfigurationPolicyNormalizer::class,
GroupPolicyConfigurationNormalizer::class, GroupPolicyConfigurationNormalizer::class,
SettingsCatalogPolicyNormalizer::class, SettingsCatalogPolicyNormalizer::class,
WindowsFeatureUpdateProfileNormalizer::class,
WindowsQualityUpdateProfileNormalizer::class,
WindowsUpdateRingNormalizer::class, WindowsUpdateRingNormalizer::class,
], ],
'policy-type-normalizers' 'policy-type-normalizers'

View File

@ -77,6 +77,16 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
$metadata = Arr::except($response->data, ['payload']); $metadata = Arr::except($response->data, ['payload']);
$metadataWarnings = $metadata['warnings'] ?? []; $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') { if ($policy->policy_type === 'settingsCatalogPolicy') {
[$payload, $metadata] = $this->hydrateSettingsCatalog( [$payload, $metadata] = $this->hydrateSettingsCatalog(
tenantIdentifier: $tenantIdentifier, 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 private function isMetadataOnlyPolicyType(string $policyType): bool
{ {
foreach (config('tenantpilot.supported_policy_types', []) as $type) { foreach (config('tenantpilot.supported_policy_types', []) as $type) {

View File

@ -555,6 +555,23 @@ public function execute(
$payload, $payload,
$graphOptions + ['method' => $updateMethod] $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 { } else {
$response = $this->graphClient->applyPolicy( $response = $this->graphClient->applyPolicy(
$item->policy_type, $item->policy_type,

View File

@ -0,0 +1,107 @@
<?php
namespace App\Services\Intune;
use Carbon\CarbonImmutable;
use Illuminate\Support\Arr;
class WindowsFeatureUpdateProfileNormalizer implements PolicyTypeNormalizer
{
public function __construct(
private readonly DefaultPolicyNormalizer $defaultNormalizer,
) {}
public function supports(string $policyType): bool
{
return $policyType === 'windowsFeatureUpdateProfile';
}
/**
* @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'][] = $this->buildFeatureUpdateBlock($snapshot);
$normalized['settings'] = array_values(array_filter($normalized['settings']));
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);
}
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;
}
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace App\Services\Intune;
use Illuminate\Support\Arr;
class WindowsQualityUpdateProfileNormalizer implements PolicyTypeNormalizer
{
public function __construct(
private readonly DefaultPolicyNormalizer $defaultNormalizer,
) {}
public function supports(string $policyType): bool
{
return $policyType === 'windowsQualityUpdateProfile';
}
/**
* @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;
}
$block = $this->buildQualityUpdateBlock($snapshot);
if ($block !== null) {
$normalized['settings'][] = $block;
$normalized['settings'] = array_values(array_filter($normalized['settings']));
}
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);
}
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,
];
}
}

View File

@ -29,6 +29,14 @@ protected static function odataTypeMap(): array
'windows' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', 'windows' => '#microsoft.graph.windowsUpdateForBusinessConfiguration',
'all' => '#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' => [ 'deviceCompliancePolicy' => [
'windows' => '#microsoft.graph.windows10CompliancePolicy', 'windows' => '#microsoft.graph.windows10CompliancePolicy',
'ios' => '#microsoft.graph.iosCompliancePolicy', 'ios' => '#microsoft.graph.iosCompliancePolicy',

View File

@ -143,6 +143,13 @@
'update_method' => 'PATCH', 'update_method' => 'PATCH',
'id_field' => 'id', 'id_field' => 'id',
'hydration' => 'properties', 'hydration' => 'properties',
'update_strip_keys' => [
'version',
'qualityUpdatesPauseStartDate',
'featureUpdatesPauseStartDate',
'qualityUpdatesWillBeRolledBack',
'featureUpdatesWillBeRolledBack',
],
'assignments_list_path' => '/deviceManagement/deviceConfigurations/{id}/assignments', 'assignments_list_path' => '/deviceManagement/deviceConfigurations/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceConfigurations/{id}/assign', 'assignments_create_path' => '/deviceManagement/deviceConfigurations/{id}/assign',
'assignments_create_method' => 'POST', 'assignments_create_method' => 'POST',
@ -153,6 +160,52 @@
'supports_scope_tags' => true, 'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds', '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' => [ 'deviceCompliancePolicy' => [
'resource' => 'deviceManagement/deviceCompliancePolicies', 'resource' => 'deviceManagement/deviceCompliancePolicies',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'],

View File

@ -8,7 +8,7 @@
'category' => 'Configuration', 'category' => 'Configuration',
'platform' => 'all', 'platform' => 'all',
'endpoint' => 'deviceManagement/deviceConfigurations', 'endpoint' => 'deviceManagement/deviceConfigurations',
'filter' => "@odata.type ne '#microsoft.graph.windowsUpdateForBusinessConfiguration'", 'filter' => "not isof('microsoft.graph.windowsUpdateForBusinessConfiguration')",
'backup' => 'full', 'backup' => 'full',
'restore' => 'enabled', 'restore' => 'enabled',
'risk' => 'medium', 'risk' => 'medium',
@ -39,11 +39,31 @@
'category' => 'Update Management', 'category' => 'Update Management',
'platform' => 'windows', 'platform' => 'windows',
'endpoint' => 'deviceManagement/deviceConfigurations', 'endpoint' => 'deviceManagement/deviceConfigurations',
'filter' => "@odata.type eq '#microsoft.graph.windowsUpdateForBusinessConfiguration'", 'filter' => "isof('microsoft.graph.windowsUpdateForBusinessConfiguration')",
'backup' => 'full', 'backup' => 'full',
'restore' => 'enabled', 'restore' => 'enabled',
'risk' => 'medium-high', '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', 'type' => 'deviceCompliancePolicy',
'label' => 'Device Compliance', 'label' => 'Device Compliance',
@ -130,7 +150,7 @@
'category' => 'Enrollment', 'category' => 'Enrollment',
'platform' => 'all', 'platform' => 'all',
'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations',
'filter' => "@odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'", 'filter' => "isof('microsoft.graph.windows10EnrollmentCompletionPageConfiguration')",
'backup' => 'full', 'backup' => 'full',
'restore' => 'enabled', 'restore' => 'enabled',
'risk' => 'medium', 'risk' => 'medium',

View File

@ -18,7 +18,9 @@
</include> </include>
</source> </source>
<php> <php>
<ini name="memory_limit" value="512M"/>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="INTUNE_TENANT_ID" value="" force="true"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/> <env name="BROADCAST_CONNECTION" value="null"/>

View File

@ -7,9 +7,12 @@ # Implementation Plan: Windows Update Rings (012)
## Summary ## Summary
Make `windowsUpdateRing` snapshots/restores accurate by correctly capturing and applying its settings, and present a readable normalized view in Filament. 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 ## Execution Steps
1. **Graph contract verification**: Ensure `config/graph_contracts.php` entry for `windowsUpdateRing` is correct and complete. 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. 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. 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. 4. **UI normalization**: Add a dedicated normalizer for `windowsUpdateRing` that renders configured settings as readable rows in the Filament UI.
5. **Tests + formatting**: Add targeted Pest tests for snapshot hydration, normalized display, and restore functionality. Run `./vendor/bin/pint --dirty` and the affected tests. 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.

View File

@ -8,6 +8,10 @@ # Feature Specification: Windows Update Rings (012)
## Overview ## Overview
Add reliable coverage for **Windows Update Rings** (`windowsUpdateRing`) in the existing inventory/backup/version/restore flows. 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. 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 ## In Scope
@ -17,6 +21,18 @@ ## In Scope
- Restore: Restore a Windows Update Ring policy from a snapshot. - 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. - 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) ## Out of Scope (v1)
- Advanced analytics or reporting on update compliance. - Advanced analytics or reporting on update compliance.
- Per-setting partial restore. - Per-setting partial restore.
@ -43,3 +59,19 @@ ### User Story 3 — Restore settings
**Acceptance** **Acceptance**
1. The restore operation correctly applies the settings from the snapshot to the target policy in Intune. 1. The restore operation correctly applies the settings from the snapshot to the target policy in Intune.
2. The restore process is audited. 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.

View File

@ -4,20 +4,23 @@ # Tasks: Windows Update Rings (012)
**Input**: [spec.md](./spec.md), [plan.md](./plan.md) **Input**: [spec.md](./spec.md), [plan.md](./plan.md)
## Phase 1: Contracts + Snapshot Hydration ## Phase 1: Contracts + Snapshot Hydration
- [ ] T001 Verify `config/graph_contracts.php` for `windowsUpdateRing` (resource, allowed_select, type_family, etc.). - [X] T001 Verify `config/graph_contracts.php` for `windowsUpdateRing` (resource, allowed_select, type_family, etc.).
- [ ] T002 Extend `PolicySnapshotService` to hydrate `windowsUpdateForBusinessConfiguration` settings. - [X] T002 Extend `PolicySnapshotService` to hydrate `windowsUpdateForBusinessConfiguration` settings.
- [X] T001b Fix Graph filters for update rings (`isof(...)`) and add `windowsFeatureUpdateProfile` support.
## Phase 2: Restore ## Phase 2: Restore
- [ ] T003 Implement restore apply for `windowsUpdateRing` settings in `RestoreService.php`. - [X] T003 Implement restore apply for `windowsUpdateRing` settings in `RestoreService.php`.
## Phase 3: UI Normalization ## Phase 3: UI Normalization
- [ ] T004 Add `WindowsUpdateRingNormalizer` and register it (Policy “Normalized settings” is readable). - [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 ## Phase 4: Tests + Verification
- [ ] T005 Add tests for hydration + UI display. - [X] T005 Add tests for sync filters + supported types.
- [ ] T006 Add tests for restore apply. - [X] T006 Add tests for restore apply.
- [ ] T007 Run tests (targeted). - [X] T007 Run tests (targeted).
- [ ] T008 Run Pint (`./vendor/bin/pint --dirty`). - [X] T008 Run Pint (`./vendor/bin/pint --dirty`).
## Open TODOs (Follow-up) ## Open TODOs (Follow-up)
- None yet. - None yet.

View File

@ -11,6 +11,7 @@
test('progress widget shows running operations for current tenant and user', function () { test('progress widget shows running operations for current tenant and user', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$user = User::factory()->create(); $user = User::factory()->create();
// Own running op // Own running op
@ -39,9 +40,6 @@
'status' => 'running', 'status' => 'running',
]); ]);
// $tenant->makeCurrent();
$tenant->forceFill(['is_current' => true])->save();
auth()->login($user); // Login user explicitly for auth()->id() call in component auth()->login($user); // Login user explicitly for auth()->id() call in component
Livewire::actingAs($user) Livewire::actingAs($user)

View File

@ -12,7 +12,7 @@
test('policy detail shows app protection settings in readable sections', function () { test('policy detail shows app protection settings in readable sections', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,

View File

@ -73,7 +73,7 @@ public function request(string $method, string $path, array $options = []): Grap
}); });
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
]); ]);

View File

@ -23,6 +23,8 @@
'name' => 'Tenant', 'name' => 'Tenant',
]); ]);
$tenant->makeCurrent();
$backupSet = BackupSet::create([ $backupSet = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'name' => 'Set 1', 'name' => 'Set 1',
@ -60,6 +62,8 @@
'name' => 'Tenant 2', 'name' => 'Tenant 2',
]); ]);
$tenant->makeCurrent();
$backupSet = BackupSet::create([ $backupSet = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'name' => 'Set with restore', 'name' => 'Set with restore',
@ -93,6 +97,8 @@
'name' => 'Tenant Force', 'name' => 'Tenant Force',
]); ]);
$tenant->makeCurrent();
$backupSet = BackupSet::create([ $backupSet = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'name' => 'Set force', 'name' => 'Set force',
@ -132,6 +138,8 @@
'name' => 'Tenant Restore Backup Set', 'name' => 'Tenant Restore Backup Set',
]); ]);
$tenant->makeCurrent();
$backupSet = BackupSet::create([ $backupSet = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'name' => 'Set restore', 'name' => 'Set restore',
@ -171,6 +179,8 @@
'name' => 'Tenant Restore Run', 'name' => 'Tenant Restore Run',
]); ]);
$tenant->makeCurrent();
$backupSet = BackupSet::create([ $backupSet = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'name' => 'Set RR', 'name' => 'Set RR',
@ -207,6 +217,8 @@
'name' => 'Tenant Restore Restore Run', 'name' => 'Tenant Restore Restore Run',
]); ]);
$tenant->makeCurrent();
$backupSet = BackupSet::create([ $backupSet = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'name' => 'Set for restore run restore', 'name' => 'Set for restore run restore',

View File

@ -13,7 +13,7 @@
test('malformed snapshot renders warning on policy and version detail', function () { test('malformed snapshot renders warning on policy and version detail', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,

View File

@ -50,7 +50,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
}); });
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,

View File

@ -8,7 +8,7 @@
test('policies are listed for the active tenant', function () { test('policies are listed for the active tenant', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
]); ]);

View File

@ -12,13 +12,12 @@
test('policy detail shows normalized settings section', function () { test('policy detail shows normalized settings section', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([

View File

@ -12,13 +12,12 @@
test('policy version detail renders tabs and scroll-safe blocks', function () { test('policy version detail renders tabs and scroll-safe blocks', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([

View File

@ -12,13 +12,12 @@
test('policy version view shows scope tags even when assignments are missing', function () { test('policy version view shows scope tags even when assignments are missing', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([

View File

@ -12,13 +12,12 @@
test('policy version detail shows raw and normalized settings', function () { test('policy version detail shows raw and normalized settings', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([

View File

@ -11,11 +11,13 @@
test('policy versions render with timeline data', function () { test('policy versions render with timeline data', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
]); ]);
$tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'external_id' => 'policy-1', 'external_id' => 'policy-1',

View File

@ -13,13 +13,12 @@
it('shows Settings tab for Settings Catalog policy', function () { it('shows Settings tab for Settings Catalog policy', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
@ -86,13 +85,12 @@
it('shows display names instead of definition IDs', function () { it('shows display names instead of definition IDs', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
@ -143,13 +141,12 @@
it('shows fallback prettified labels when definitions not cached', function () { it('shows fallback prettified labels when definitions not cached', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
@ -195,13 +192,12 @@
it('shows tabbed layout for non-Settings Catalog policies', function () { it('shows tabbed layout for non-Settings Catalog policies', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
@ -242,7 +238,7 @@
// T034: Test display names shown (not definition IDs) // T034: Test display names shown (not definition IDs)
it('displays setting display names instead of raw definition IDs', function () { it('displays setting display names instead of raw definition IDs', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'is_current' => true, 'is_current' => true,
]); ]);
@ -296,7 +292,7 @@
// T035: Test values formatted correctly // T035: Test values formatted correctly
it('formats setting values correctly based on type', function () { it('formats setting values correctly based on type', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'is_current' => true, 'is_current' => true,
]); ]);
@ -370,7 +366,7 @@
// T036: Test search/filter functionality // T036: Test search/filter functionality
it('search filters settings in real-time', function () { it('search filters settings in real-time', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'is_current' => true, 'is_current' => true,
]); ]);
@ -433,7 +429,7 @@
// T037: Test graceful degradation for missing definitions // T037: Test graceful degradation for missing definitions
it('shows prettified fallback labels when definitions are not cached', function () { it('shows prettified fallback labels when definitions are not cached', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'is_current' => true, 'is_current' => true,
]); ]);

View File

@ -13,13 +13,12 @@
test('settings catalog policies render a normalized settings table', function () { test('settings catalog policies render a normalized settings table', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([

View File

@ -75,16 +75,11 @@ public function request(string $method, string $path, array $options = []): Grap
app()->instance(GraphClientInterface::class, new SettingsCatalogFakeGraphClient($responses)); app()->instance(GraphClientInterface::class, new SettingsCatalogFakeGraphClient($responses));
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, '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(); $tenant->makeCurrent();
expect(Tenant::current()->id)->toBe($tenant->id); expect(Tenant::current()->id)->toBe($tenant->id);

View File

@ -13,13 +13,12 @@
test('settings catalog settings render as a filament table with details action', function () { test('settings catalog settings render as a filament table with details action', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([

View File

@ -0,0 +1,213 @@
<?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\RestoreService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
class WindowsUpdateProfilesRestoreGraphClient implements GraphClientInterface
{
/**
* @var array<int, array{policyType:string,policyId:string,payload:array,options:array}>
*/
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');
});

View File

@ -12,13 +12,12 @@
test('policy detail shows normalized settings for windows update ring', function () { test('policy detail shows normalized settings for windows update ring', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([

View File

@ -18,6 +18,8 @@
{ {
public array $applied = []; public array $applied = [];
public array $requests = [];
public function listPolicies(string $policyType, array $options = []): GraphResponse public function listPolicies(string $policyType, array $options = []): GraphResponse
{ {
return new GraphResponse(true, []); return new GraphResponse(true, []);
@ -47,6 +49,12 @@ public function applyPolicy(string $policyType, string $policyId, array $payload
public function request(string $method, string $path, array $options = []): GraphResponse 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, []); return new GraphResponse(true, []);
} }
@ -80,9 +88,15 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
]); ]);
$backupPayload = [ $backupPayload = [
'id' => 'policy-wuring',
'@odata.type' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', '@odata.type' => '#microsoft.graph.windowsUpdateForBusinessConfiguration',
'automaticUpdateMode' => 'autoInstallAtMaintenanceTime', 'automaticUpdateMode' => 'autoInstallAtMaintenanceTime',
'featureUpdatesDeferralPeriodInDays' => 14, 'featureUpdatesDeferralPeriodInDays' => 14,
'version' => 7,
'qualityUpdatesPauseStartDate' => '2025-01-01T00:00:00Z',
'featureUpdatesPauseStartDate' => '2025-01-02T00:00:00Z',
'qualityUpdatesWillBeRolledBack' => false,
'featureUpdatesWillBeRolledBack' => false,
'roleScopeTagIds' => ['0'], 'roleScopeTagIds' => ['0'],
]; ];
@ -119,8 +133,19 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1); expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1);
expect($client->applied)->toHaveCount(1); expect($client->requests)->toHaveCount(1);
expect($client->applied[0]['policyType'])->toBe('windowsUpdateRing'); expect($client->requests[0]['method'])->toBe('PATCH');
expect($client->applied[0]['policyId'])->toBe('policy-wuring'); expect($client->requests[0]['path'])->toBe('deviceManagement/deviceConfigurations/policy-wuring/microsoft.graph.windowsUpdateForBusinessConfiguration');
expect($client->applied[0]['payload']['automaticUpdateMode'])->toBe('autoInstallAtMaintenanceTime');
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');
}); });

View File

@ -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 () { test('sync skips managed app configurations from app protection inventory', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant'), 'tenant_id' => 'test-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,

View File

@ -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 () { test('sync revives ignored policies when they exist in Intune', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant'), 'tenant_id' => 'test-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'metadata' => [], 'metadata' => [],
'is_current' => true, '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 () { test('sync creates new policies even if ignored ones exist with same external_id', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant-2'), 'tenant_id' => 'test-tenant-2',
'name' => 'Test Tenant 2', 'name' => 'Test Tenant 2',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,

View File

@ -0,0 +1,77 @@
<?php
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('marks targeted managed app configurations as ignored during sync', function () {
$tenant = Tenant::factory()->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');
});

View File

@ -4,4 +4,13 @@
use Illuminate\Foundation\Testing\TestCase as BaseTestCase; 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']);
}
}

View File

@ -115,7 +115,7 @@ public function request(string $method, string $path, array $options = []): Grap
expect($result['items'][1]['source_id'])->toBe('filter-2'); expect($result['items'][1]['source_id'])->toBe('filter-2');
expect($client->requests[0]['path'])->toBe('deviceManagement/assignmentFilters'); 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]['path'])->toBe('deviceManagement/assignmentFilters?$skiptoken=abc');
expect($client->requests[1]['options']['query'])->toBe([]); expect($client->requests[1]['options']['query'])->toBe([]);
}); });

View File

@ -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'])->toContain('roleScopeTagIds');
expect($client->requests[0][3]['select'])->not->toContain('@odata.type'); 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');
});

View File

@ -108,3 +108,27 @@ function restoreIntuneTenantId(string|false $original): void
restoreIntuneTenantId($originalEnv); 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);
});