TenantAtlas/tests/Unit/PolicySnapshotServiceTest.php
ahmido 412dd7ad66 feat/017-policy-types-mam-endpoint-security-baselines (#23)
Hydrate configurationPolicies/{id}/settings for endpoint security/baseline policies so snapshots include real rule data.
Treat those types like Settings Catalog policies in the normalizer so they show the searchable settings table, recognizable categories, and readable choice values (firewall-specific formatting + interface badge parsing).
Improve “General” tab cards: badge lists for platforms/technologies, template reference summary (name/family/version/ID), and ISO timestamps rendered as YYYY‑MM‑DD HH:MM:SS; added regression test for the view.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #23
2026-01-03 02:06:35 +00:00

547 lines
20 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' => '...'],
],
]);
}
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');
});
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');
});