feat(rings): update rings + update profiles

- Fix update ring inventory filter using isof()

- Hydrate + restore Windows Update Ring via derived-type endpoint

- Add Windows Feature/Quality Update Profile support

- Stabilize tenant selection in tests
This commit is contained in:
Ahmed Darrazi 2026-01-01 11:42:50 +01:00
parent c1fbb4620f
commit 074a65669b
39 changed files with 887 additions and 64 deletions

View File

@ -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()

View File

@ -10,6 +10,8 @@
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;
@ -41,6 +43,8 @@ public function register(): void
DeviceConfigurationPolicyNormalizer::class,
GroupPolicyConfigurationNormalizer::class,
SettingsCatalogPolicyNormalizer::class,
WindowsFeatureUpdateProfileNormalizer::class,
WindowsQualityUpdateProfileNormalizer::class,
WindowsUpdateRingNormalizer::class,
],
'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']);
$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) {

View File

@ -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,

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',
'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',

View File

@ -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'],

View File

@ -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',

View File

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

View File

@ -7,9 +7,12 @@ # Implementation Plan: Windows Update Rings (012)
## 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. **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
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
@ -17,6 +21,18 @@ ## In Scope
- 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.
@ -43,3 +59,19 @@ ### User Story 3 — Restore settings
**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.

View File

@ -4,20 +4,23 @@ # Tasks: Windows Update Rings (012)
**Input**: [spec.md](./spec.md), [plan.md](./plan.md)
## Phase 1: Contracts + Snapshot Hydration
- [ ] T001 Verify `config/graph_contracts.php` for `windowsUpdateRing` (resource, allowed_select, type_family, etc.).
- [ ] T002 Extend `PolicySnapshotService` to hydrate `windowsUpdateForBusinessConfiguration` settings.
- [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
- [ ] 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
- [ ] 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
- [ ] T005 Add tests for hydration + UI display.
- [ ] T006 Add tests for restore apply.
- [ ] T007 Run tests (targeted).
- [ ] T008 Run Pint (`./vendor/bin/pint --dirty`).
- [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.

View File

@ -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)

View File

@ -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,

View File

@ -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' => [],
]);

View File

@ -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',

View File

@ -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,

View File

@ -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,

View File

@ -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' => [],
]);

View File

@ -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([

View File

@ -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([

View File

@ -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([

View File

@ -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([

View File

@ -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',

View File

@ -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,
]);

View File

@ -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([

View File

@ -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);

View File

@ -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([

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 () {
$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([

View File

@ -18,6 +18,8 @@
{
public array $applied = [];
public array $requests = [];
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
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
{
$this->requests[] = [
'method' => strtoupper($method),
'path' => $path,
'payload' => $options['json'] ?? null,
];
return new GraphResponse(true, []);
}
@ -80,9 +88,15 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
]);
$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'],
];
@ -119,8 +133,19 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1);
expect($client->applied)->toHaveCount(1);
expect($client->applied[0]['policyType'])->toBe('windowsUpdateRing');
expect($client->applied[0]['policyId'])->toBe('policy-wuring');
expect($client->applied[0]['payload']['automaticUpdateMode'])->toBe('autoInstallAtMaintenanceTime');
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');
});

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 () {
$tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant'),
'tenant_id' => 'test-tenant',
'name' => 'Test Tenant',
'metadata' => [],
'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 () {
$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,

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;
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($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([]);
});

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'])->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);
});
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);
});