TenantAtlas/tests/Unit/PolicySnapshotServiceTest.php
ahmido 69d98e925c feat/018-driver-updates-wufb (#27)
Tenants: Tenant anlegen/öffnen → tenant_id, app_client_id, app_client_secret setzen → Make current (wichtig).
Inventory → Policies: oben Sync from Intune.
In der Tabelle nach Type = “Driver Updates (Windows)” (windowsDriverUpdateProfile) filtern und Policy öffnen.
Auf der Policy: Settings-Tab prüfen (Block „Driver Update Profile“), dann Capture snapshot klicken und unter Versions die Version ansehen.
Restore-Test (nur im Test-Tenant!): Version öffnen → Restore to Intune erst als Dry-run, dann Execute; danach unter Backups & Restore → Restore Runs Ergebnis prüfen (soll graph_path mit deviceManagement/windowsDriverUpdateProfiles/... zeigen).

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #27
2026-01-04 00:38:54 +00:00

603 lines
22 KiB
PHP

<?php
use App\Models\Policy;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\PolicySnapshotService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class);
uses(RefreshDatabase::class);
class PolicySnapshotGraphClient 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];
if ($policyType === 'mobileApp') {
return new GraphResponse(success: true, data: [
'payload' => [
'id' => $policyId,
'displayName' => 'Contoso Portal',
'publisher' => 'Contoso',
'description' => 'Company Portal',
'@odata.type' => '#microsoft.graph.win32LobApp',
'createdDateTime' => '2025-01-01T00:00:00Z',
'lastModifiedDateTime' => '2025-01-02T00:00:00Z',
'roleScopeTagIds' => ['0', 'tag-1', 'tag-2'],
'installCommandLine' => 'setup.exe /quiet',
'largeIcon' => ['type' => 'image/png', 'value' => '...'],
],
]);
}
if ($policyType === 'windowsDriverUpdateProfile') {
return new GraphResponse(success: true, data: [
'payload' => [
'id' => $policyId,
'displayName' => 'Driver Updates A',
'description' => 'Drivers rollout policy',
'@odata.type' => '#microsoft.graph.windowsDriverUpdateProfile',
'approvalType' => 'automatic',
'deploymentDeferralInDays' => 7,
'deviceReporting' => 12,
'newUpdates' => 3,
'roleScopeTagIds' => ['0'],
'inventorySyncStatus' => [
'@odata.type' => '#microsoft.graph.windowsDriverUpdateProfileInventorySyncStatus',
'driverInventorySyncState' => 'success',
'lastSuccessfulSyncDateTime' => '2026-01-01T00:00:00Z',
],
],
]);
}
return new GraphResponse(success: true, data: [
'payload' => [
'id' => $policyId,
'displayName' => 'Compliance Alpha',
'@odata.type' => '#microsoft.graph.windows10CompliancePolicy',
],
]);
}
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 (str_contains($path, 'scheduledActionsForRule')) {
return new GraphResponse(success: true, data: [
'value' => [
[
'ruleName' => 'Default rule',
'scheduledActionConfigurations' => [
[
'actionType' => 'notification',
'notificationTemplateId' => 'template-123',
],
],
],
],
]);
}
return new GraphResponse(success: true, data: []);
}
}
class ConfigurationPolicySettingsSnapshotGraphClient implements GraphClientInterface
{
public array $requests = [];
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(success: true, data: []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
$this->requests[] = ['getPolicy', $policyType, $policyId, $options];
return new GraphResponse(success: true, data: [
'payload' => [
'id' => $policyId,
'name' => 'Endpoint Security Alpha',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
],
]);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(success: true, data: []);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return new GraphResponse(success: true, data: []);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(success: true, data: []);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
$this->requests[] = [$method, $path, $options];
if ($method === 'GET' && str_contains($path, 'deviceManagement/configurationPolicies/') && str_ends_with($path, '/settings')) {
return new GraphResponse(success: true, data: [
'value' => [
[
'id' => 'setting-1',
'settingInstance' => [
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance',
'settingDefinitionId' => 'device_vendor_msft_policy_config_firewall_policy_alpha',
'simpleSettingValue' => [
'value' => true,
],
],
],
],
]);
}
return new GraphResponse(success: true, data: []);
}
}
it('hydrates compliance policy scheduled actions into snapshots', function () {
$client = new PolicySnapshotGraphClient;
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-compliance',
'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' => 'compliance-123',
'policy_type' => 'deviceCompliancePolicy',
'display_name' => 'Compliance Alpha',
'platform' => 'windows',
]);
$service = app(PolicySnapshotService::class);
$result = $service->fetch($tenant, $policy);
expect($result)->toHaveKey('payload');
expect($result['payload'])->toHaveKey('scheduledActionsForRule');
expect($result['payload']['scheduledActionsForRule'])->toHaveCount(1);
expect($result['payload']['scheduledActionsForRule'][0]['scheduledActionConfigurations'][0]['notificationTemplateId'])
->toBe('template-123');
expect($result['metadata']['compliance_actions_hydration'])->toBe('complete');
expect($client->requests[0][0])->toBe('getPolicy');
expect($client->requests[0][1])->toBe('deviceCompliancePolicy');
expect($client->requests[0][2])->toBe('compliance-123');
expect($client->requests[0][3]['expand'] ?? null)
->toBe('scheduledActionsForRule($expand=scheduledActionConfigurations)');
});
it('hydrates configuration policy settings into snapshots', function (string $policyType) {
$client = new ConfigurationPolicySettingsSnapshotGraphClient;
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-endpoint-security',
'app_client_id' => 'client-123',
'app_client_secret' => 'secret-123',
'is_current' => true,
]);
$tenant->makeCurrent();
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'esp-123',
'policy_type' => $policyType,
'display_name' => 'Endpoint Security Alpha',
'platform' => 'windows',
]);
$service = app(PolicySnapshotService::class);
$result = $service->fetch($tenant, $policy);
expect($result)->toHaveKey('payload');
expect($result['payload'])->toHaveKey('settings');
expect($result['payload']['settings'])->toHaveCount(1);
expect($result['metadata']['settings_hydration'] ?? null)->toBe('complete');
$paths = collect($client->requests)
->filter(fn (array $entry): bool => ($entry[0] ?? null) === 'GET')
->map(fn (array $entry): string => (string) ($entry[1] ?? ''))
->values();
expect($paths->contains(fn (string $path): bool => str_contains($path, 'deviceManagement/configurationPolicies/esp-123/settings')))->toBeTrue();
})->with([
'endpointSecurityPolicy',
'securityBaselinePolicy',
]);
it('filters mobile app snapshots to metadata-only keys', function () {
$client = new PolicySnapshotGraphClient;
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-apps',
'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' => 'app-123',
'policy_type' => 'mobileApp',
'display_name' => 'Contoso Portal',
'platform' => 'all',
]);
$service = app(PolicySnapshotService::class);
$result = $service->fetch($tenant, $policy);
expect($result['payload'])->toHaveKeys([
'id',
'displayName',
'publisher',
'description',
'@odata.type',
'createdDateTime',
'lastModifiedDateTime',
'roleScopeTagIds',
]);
expect($result['payload']['roleScopeTagIds'])->toBe(['0', 'tag-1', 'tag-2']);
expect($result['payload'])->not->toHaveKey('installCommandLine');
expect($result['payload'])->not->toHaveKey('largeIcon');
expect($client->requests[0][0])->toBe('getPolicy');
expect($client->requests[0][1])->toBe('mobileApp');
expect($client->requests[0][2])->toBe('app-123');
expect($client->requests[0][3]['select'] ?? null)->toBeArray();
expect($client->requests[0][3]['select'])->toContain('displayName');
expect($client->requests[0][3]['select'])->toContain('roleScopeTagIds');
expect($client->requests[0][3]['select'])->not->toContain('@odata.type');
});
it('captures windows driver update profile snapshots with full payload', function () {
$client = new PolicySnapshotGraphClient;
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-driver',
'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' => 'wdp-123',
'policy_type' => 'windowsDriverUpdateProfile',
'display_name' => 'Driver Updates A',
'platform' => 'windows',
]);
$service = app(PolicySnapshotService::class);
$result = $service->fetch($tenant, $policy);
expect($result)->toHaveKey('payload');
expect($result['payload']['approvalType'] ?? null)->toBe('automatic');
expect($result['payload']['deploymentDeferralInDays'] ?? null)->toBe(7);
expect($result['payload']['deviceReporting'] ?? null)->toBe(12);
expect($result['payload']['newUpdates'] ?? null)->toBe(3);
expect($result['payload']['inventorySyncStatus']['driverInventorySyncState'] ?? null)->toBe('success');
expect($client->requests[0][0])->toBe('getPolicy');
expect($client->requests[0][1])->toBe('windowsDriverUpdateProfile');
expect($client->requests[0][2])->toBe('wdp-123');
});
test('falls back to metadata-only snapshot when mamAppConfiguration returns 500', function () {
$client = Mockery::mock(\App\Services\Graph\GraphClientInterface::class);
$client->shouldReceive('getPolicy')
->once()
->andThrow(new \App\Services\Graph\GraphException('InternalServerError: upstream', 500));
app()->instance(\App\Services\Graph\GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-mam-fallback',
'app_client_id' => 'client-123',
'app_client_secret' => 'secret-123',
'is_current' => true,
]);
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'A_fallback-policy',
'policy_type' => 'mamAppConfiguration',
'display_name' => 'MAM Config Alpha',
'platform' => 'iOS',
]);
$service = app(\App\Services\Intune\PolicySnapshotService::class);
$result = $service->fetch($tenant, $policy);
expect($result)->toHaveKey('payload');
expect($result)->toHaveKey('metadata');
expect($result)->toHaveKey('warnings');
expect($result['payload']['id'])->toBe('A_fallback-policy');
expect($result['payload']['displayName'])->toBe('MAM Config Alpha');
expect($result['payload']['@odata.type'])->toBe('#microsoft.graph.targetedManagedAppConfiguration');
expect($result['payload']['platform'])->toBe('iOS');
expect($result['metadata']['source'])->toBe('metadata_only');
expect($result['metadata']['original_status'])->toBe(500);
expect($result['warnings'])->toHaveCount(1);
expect($result['warnings'][0])->toContain('Snapshot captured from local metadata only');
expect($result['warnings'][0])->toContain('Restore preview available, full restore not possible');
});
test('does not fallback to metadata for non-5xx errors', function () {
$client = Mockery::mock(\App\Services\Graph\GraphClientInterface::class);
$client->shouldReceive('getPolicy')
->once()
->andThrow(new \App\Services\Graph\GraphException('NotFound', 404));
app()->instance(\App\Services\Graph\GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-404',
'app_client_id' => 'client-123',
'app_client_secret' => 'secret-123',
'is_current' => true,
]);
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'A_missing',
'policy_type' => 'mamAppConfiguration',
'display_name' => 'Missing Policy',
'platform' => 'iOS',
]);
$service = app(\App\Services\Intune\PolicySnapshotService::class);
$result = $service->fetch($tenant, $policy);
expect($result)->toHaveKey('failure');
expect($result['failure']['status'])->toBe(404);
expect($result['failure']['reason'])->toContain('NotFound');
});
test('falls back to metadata-only when graph client returns failed response for mamAppConfiguration', function () {
$client = Mockery::mock(\App\Services\Graph\GraphClientInterface::class);
$client->shouldReceive('getPolicy')
->once()
->andReturn(new \App\Services\Graph\GraphResponse(
success: false,
data: [
'error' => [
'code' => 'InternalServerError',
'message' => 'Upstream MAM failure',
],
],
status: 500,
errors: [['code' => 'InternalServerError', 'message' => 'Upstream MAM failure']],
meta: [
'client_request_id' => 'client-req-1',
'request_id' => 'req-1',
],
));
app()->instance(\App\Services\Graph\GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-mam-fallback-response',
'app_client_id' => 'client-123',
'app_client_secret' => 'secret-123',
'is_current' => true,
]);
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'A_resp_fallback',
'policy_type' => 'mamAppConfiguration',
'display_name' => 'MAM Config Response',
'platform' => 'iOS',
]);
$service = app(\App\Services\Intune\PolicySnapshotService::class);
$result = $service->fetch($tenant, $policy);
expect($result)->toHaveKey('payload');
expect($result['metadata']['source'])->toBe('metadata_only');
expect($result['metadata']['original_status'])->toBe(500);
expect($result['metadata']['original_failure'])->toContain('InternalServerError');
});
class WindowsUpdateRingSnapshotGraphClient implements GraphClientInterface
{
public array $requests = [];
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');
});
class FailedSnapshotGraphClient implements GraphClientInterface
{
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(success: true, data: []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(
success: false,
data: [],
status: 500,
errors: [],
warnings: [],
meta: [
'error_code' => 'InternalServerError',
'error_message' => 'An internal server error has occurred',
'request_id' => 'req-123',
'client_request_id' => 'client-456',
],
);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(success: true, data: []);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return new GraphResponse(success: true, data: []);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(success: true, data: []);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(success: true, data: []);
}
}
it('returns actionable reasons when graph snapshot fails', function () {
app()->instance(GraphClientInterface::class, new FailedSnapshotGraphClient);
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-failure',
'app_client_id' => 'client-123',
'app_client_secret' => 'secret-123',
'is_current' => true,
]);
$tenant->makeCurrent();
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'mam-123',
'policy_type' => 'deviceCompliancePolicy',
'display_name' => 'Compliance Config',
'platform' => 'mobile',
]);
$service = app(PolicySnapshotService::class);
$result = $service->fetch($tenant, $policy);
expect($result)->toHaveKey('failure');
expect($result['failure']['status'])->toBe(500);
expect($result['failure']['reason'])->toContain('InternalServerError');
expect($result['failure']['reason'])->toContain('An internal server error has occurred');
expect($result['failure']['reason'])->toContain('client_request_id=client-456');
expect($result['failure']['reason'])->toContain('request_id=req-123');
});