TenantAtlas/tests/Feature/EndpointSecurityPolicyRestore023Test.php
ahmido d120ed7c92 feat: endpoint security restore execution (023) (#25)
Added a resolver/validation flow that fetches endpoint security template definitions and enforces them before CREATE/PATCH so we don’t call Graph with invalid settings.
Hardened restore endpoint resolution (built-in fallback to deviceManagement/configurationPolicies, clearer error metadata, preview-only fallback when metadata is missing) and exposed Graph path/method in restore UI details.
Stripped read-only fields when PATCHing endpointSecurityIntent so the request no longer fails with “properties not patchable”.
Added regression tests covering endpoint security restore, intent sanitization, unknown type safety, Graph error metadata, and endpoint resolution behavior.
Testing

GraphClientEndpointResolutionTest.php
./vendor/bin/pint --dirty

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #25
2026-01-03 22:44:08 +00:00

266 lines
9.4 KiB
PHP

<?php
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\RestoreRiskChecker;
use App\Services\Intune\RestoreService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
class EndpointSecurityRestoreGraphClient implements GraphClientInterface
{
/** @var array<int, array{policyType:string,policyId:string,payload:array,options:array<string,mixed>}> */
public array $applyPolicyCalls = [];
/** @var array<int, array{method:string,path:string,options:array<string,mixed>}> */
public array $requestCalls = [];
/**
* @param array<string, GraphResponse> $requestMap
*/
public function __construct(
private readonly GraphResponse $applyPolicyResponse,
private readonly array $requestMap = [],
) {}
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 $this->applyPolicyResponse;
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
$this->requestCalls[] = [
'method' => strtoupper($method),
'path' => $path,
'options' => $options,
];
foreach ($this->requestMap as $needle => $response) {
if (is_string($needle) && $needle !== '' && str_contains($path, $needle)) {
return $response;
}
}
return new GraphResponse(true, []);
}
}
test('restore executes endpoint security policy settings via settings endpoint', function () {
$client = new EndpointSecurityRestoreGraphClient(new GraphResponse(true, []));
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create();
$backupSet = BackupSet::factory()->for($tenant)->create([
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([
'policy_id' => null,
'policy_identifier' => 'esp-1',
'policy_type' => 'endpointSecurityPolicy',
'platform' => 'windows',
'payload' => [
'id' => 'esp-1',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'name' => 'Endpoint Security Policy',
'platforms' => ['windows10'],
'technologies' => ['endpointSecurity'],
'templateReference' => [
'templateId' => 'template-1',
'templateFamily' => 'endpointSecurityFirewall',
'templateDisplayName' => 'Windows Firewall Rules',
'templateDisplayVersion' => 'Version 1',
],
'settings' => [
[
'id' => 's1',
'settingInstance' => [
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance',
'settingDefinitionId' => 'device_vendor_msft_policy_config_defender_allowrealtimemonitoring',
'simpleSettingValue' => [
'value' => 1,
],
],
],
],
],
'assignments' => null,
]);
$service = app(RestoreService::class);
$run = $service->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: [$backupItem->id],
dryRun: false,
);
expect($run->status)->toBe('completed');
expect($client->applyPolicyCalls)->toHaveCount(1);
expect($client->applyPolicyCalls[0]['policyType'])->toBe('endpointSecurityPolicy');
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('settings');
$settingsCalls = collect($client->requestCalls)
->filter(fn (array $call) => $call['method'] === 'POST' && str_contains($call['path'], '/settings'))
->values();
expect($settingsCalls)->toHaveCount(1);
expect($settingsCalls[0]['path'])->toContain('deviceManagement/configurationPolicies/esp-1/settings');
$body = $settingsCalls[0]['options']['json'] ?? null;
expect($body)->toBeArray()->not->toBeEmpty();
expect($body[0]['settingInstance']['settingDefinitionId'] ?? null)
->toBe('device_vendor_msft_policy_config_defender_allowrealtimemonitoring');
});
test('restore fails when endpoint security template is missing', function () {
$applyNotFound = new GraphResponse(false, ['error' => ['message' => 'Not found']], 404, [], [], [
'error_code' => 'NotFound',
'error_message' => 'Not found',
]);
$templateNotFound = new GraphResponse(false, ['error' => ['message' => 'Template missing']], 404, [], [], [
'error_code' => 'NotFound',
'error_message' => 'Template missing',
]);
$client = new EndpointSecurityRestoreGraphClient($applyNotFound, [
'configurationPolicyTemplates' => $templateNotFound,
]);
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create();
$backupSet = BackupSet::factory()->for($tenant)->create([
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([
'policy_id' => null,
'policy_identifier' => 'esp-missing',
'policy_type' => 'endpointSecurityPolicy',
'platform' => 'windows',
'payload' => [
'id' => 'esp-missing',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'name' => 'Endpoint Security Policy',
'platforms' => ['windows10'],
'technologies' => ['endpointSecurity'],
'templateReference' => [
'templateId' => 'missing-template',
],
'settings' => [
[
'id' => 's1',
'settingInstance' => [
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance',
'settingDefinitionId' => 'device_vendor_msft_policy_config_defender_allowrealtimemonitoring',
'simpleSettingValue' => [
'value' => 1,
],
],
],
],
],
'assignments' => null,
]);
$service = app(RestoreService::class);
$run = $service->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: [$backupItem->id],
dryRun: false,
);
expect($run->status)->toBe('failed');
$createCalls = collect($client->requestCalls)
->filter(fn (array $call) => $call['method'] === 'POST' && $call['path'] === 'deviceManagement/configurationPolicies')
->values();
expect($createCalls)->toHaveCount(0);
});
test('restore risk checks flag missing endpoint security templates as blocking', function () {
$templateNotFound = new GraphResponse(false, ['error' => ['message' => 'Template missing']], 404, [], [], [
'error_code' => 'NotFound',
'error_message' => 'Template missing',
]);
$client = new EndpointSecurityRestoreGraphClient(new GraphResponse(true, []), [
'configurationPolicyTemplates' => $templateNotFound,
]);
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create();
$backupSet = BackupSet::factory()->for($tenant)->create([
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([
'policy_id' => null,
'policy_identifier' => 'esp-missing',
'policy_type' => 'endpointSecurityPolicy',
'platform' => 'windows',
'payload' => [
'id' => 'esp-missing',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'templateReference' => [
'templateId' => 'missing-template',
'templateFamily' => 'endpointSecurityFirewall',
],
'settings' => [],
],
'assignments' => null,
]);
$checker = app(RestoreRiskChecker::class);
$result = $checker->check(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: [$backupItem->id],
groupMapping: [],
);
$results = collect($result['results'] ?? []);
$templateCheck = $results->firstWhere('code', 'endpoint_security_templates');
expect($templateCheck)->not->toBeNull();
expect($templateCheck['severity'] ?? null)->toBe('blocking');
});