TenantAtlas/tests/Unit/FoundationMappingServiceTest.php
ahmido d2dbc52a32 feat(006): foundations + assignment mapping and preview-only restore guard (#7)
## Summary
- Capture and restore foundation types (assignment filters, scope tags, notification templates) with deterministic mapping.
- Apply foundation mappings during restore (scope tags on policy payloads, assignment filter mapping with skip reasons).
- Improve restore run UX (item selection, rerun action, preview-only badges).
- Enforce preview-only policy types (e.g. Conditional Access) during execution.

## Testing
- ./vendor/bin/sail artisan test tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php

## Notes
- Specs/plan/tasks updated under specs/006-sot-foundations-assignments.
- No migrations.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #7
2025-12-26 23:44:31 +00:00

282 lines
9.3 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\FoundationMappingService;
use App\Services\Intune\FoundationSnapshotService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery\MockInterface;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
class FoundationMappingGraphClient implements GraphClientInterface
{
public array $requests = [];
/**
* @param array<int, GraphResponse> $responses
*/
public function __construct(private array $responses = []) {}
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: true, data: []);
}
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' => $method,
'path' => $path,
'options' => $options,
];
return array_shift($this->responses) ?? new GraphResponse(success: true, data: []);
}
}
it('maps existing foundations by display name', function () {
$tenant = Tenant::factory()->create();
$backupSet = BackupSet::factory()->for($tenant)->create();
$item = BackupItem::factory()
->for($tenant)
->for($backupSet)
->state([
'policy_id' => null,
'policy_identifier' => 'filter-1',
'policy_type' => 'assignmentFilter',
'platform' => 'all',
'payload' => [
'id' => 'filter-1',
'displayName' => 'Filter One',
],
'metadata' => [
'displayName' => 'Filter One',
],
])
->create();
$this->mock(FoundationSnapshotService::class, function (MockInterface $mock) {
$mock->shouldReceive('fetchAll')
->once()
->andReturn([
'items' => [
[
'source_id' => 'filter-2',
'display_name' => 'Filter One',
'payload' => [],
'metadata' => [],
],
],
'failures' => [],
]);
});
$client = new FoundationMappingGraphClient;
app()->instance(GraphClientInterface::class, $client);
$service = app(FoundationMappingService::class);
$result = $service->map($tenant, collect([$item]), false);
expect($result['failed'])->toBe(0);
expect($result['skipped'])->toBe(0);
expect($result['mapping'])->toBe(['filter-1' => 'filter-2']);
expect($result['entries'])->toHaveCount(1);
expect($result['entries'][0]['decision'])->toBe('mapped_existing');
expect($result['entries'][0]['targetId'])->toBe('filter-2');
expect($result['entries'][0]['sourceName'])->toBe('Filter One');
});
it('creates missing foundations when executing', function () {
config()->set('graph_contracts.types.assignmentFilter', [
'resource' => 'deviceManagement/assignmentFilters',
'create_method' => 'POST',
'update_strip_keys' => ['isBuiltIn'],
]);
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-1',
'app_client_id' => 'client-1',
'app_client_secret' => 'secret-1',
]);
$backupSet = BackupSet::factory()->for($tenant)->create();
$item = BackupItem::factory()
->for($tenant)
->for($backupSet)
->state([
'policy_id' => null,
'policy_identifier' => 'filter-1',
'policy_type' => 'assignmentFilter',
'platform' => 'all',
'payload' => [
'id' => 'filter-1',
'@odata.type' => '#microsoft.graph.deviceAndAppManagementAssignmentFilter',
'displayName' => 'Filter One',
'isBuiltIn' => false,
],
'metadata' => [
'displayName' => 'Filter One',
],
])
->create();
$this->mock(FoundationSnapshotService::class, function (MockInterface $mock) {
$mock->shouldReceive('fetchAll')
->once()
->andReturn([
'items' => [
[
'source_id' => 'filter-2',
'display_name' => 'Filter One',
'payload' => [],
'metadata' => [],
],
[
'source_id' => 'filter-3',
'display_name' => 'Filter One',
'payload' => [],
'metadata' => [],
],
],
'failures' => [],
]);
});
$client = new FoundationMappingGraphClient([
new GraphResponse(true, [
'id' => 'filter-99',
'displayName' => 'Filter One (Copy)',
]),
]);
app()->instance(GraphClientInterface::class, $client);
$service = app(FoundationMappingService::class);
$result = $service->map($tenant, collect([$item]), true);
expect($result['mapping'])->toBe(['filter-1' => 'filter-99']);
expect($result['entries'][0]['decision'])->toBe('created_copy');
expect($result['entries'][0]['targetName'])->toBe('Filter One (Copy)');
expect($client->requests)->toHaveCount(1);
expect($client->requests[0]['method'])->toBe('POST');
expect($client->requests[0]['path'])->toBe('deviceManagement/assignmentFilters');
$payload = $client->requests[0]['options']['json'];
expect($payload['displayName'])->toBe('Filter One (Copy)');
expect($payload)->not->toHaveKey('id');
expect($payload)->not->toHaveKey('@odata.type');
expect($payload)->not->toHaveKey('isBuiltIn');
});
it('skips built-in scope tags', function () {
$tenant = Tenant::factory()->create();
$backupSet = BackupSet::factory()->for($tenant)->create();
$item = BackupItem::factory()
->for($tenant)
->for($backupSet)
->state([
'policy_id' => null,
'policy_identifier' => '0',
'policy_type' => 'roleScopeTag',
'platform' => 'all',
'payload' => [
'id' => '0',
'displayName' => 'Default',
'isBuiltIn' => true,
],
'metadata' => [
'displayName' => 'Default',
],
])
->create();
$this->mock(FoundationSnapshotService::class, function (MockInterface $mock) {
$mock->shouldReceive('fetchAll')
->once()
->andReturn([
'items' => [],
'failures' => [],
]);
});
$client = new FoundationMappingGraphClient;
app()->instance(GraphClientInterface::class, $client);
$service = app(FoundationMappingService::class);
$result = $service->map($tenant, collect([$item]), false);
expect($result['skipped'])->toBe(1);
expect($result['entries'][0]['decision'])->toBe('skipped');
expect($result['entries'][0]['reason'])->toBe('Built-in scope tag cannot be created.');
});
it('marks failures when foundation listing fails', function () {
$tenant = Tenant::factory()->create();
$backupSet = BackupSet::factory()->for($tenant)->create();
$item = BackupItem::factory()
->for($tenant)
->for($backupSet)
->state([
'policy_id' => null,
'policy_identifier' => 'filter-1',
'policy_type' => 'assignmentFilter',
'platform' => 'all',
'payload' => [
'id' => 'filter-1',
'displayName' => 'Filter One',
],
'metadata' => [
'displayName' => 'Filter One',
],
])
->create();
$this->mock(FoundationSnapshotService::class, function (MockInterface $mock) {
$mock->shouldReceive('fetchAll')
->once()
->andReturn([
'items' => [],
'failures' => [
[
'foundation_type' => 'assignmentFilter',
'reason' => 'Graph failure',
'status' => 500,
],
],
]);
});
$client = new FoundationMappingGraphClient;
app()->instance(GraphClientInterface::class, $client);
$service = app(FoundationMappingService::class);
$result = $service->map($tenant, collect([$item]), false);
expect($result['failed'])->toBe(1);
expect($result['entries'][0]['decision'])->toBe('failed');
expect($result['entries'][0]['reason'])->toBe('Graph failure');
});