TenantAtlas/tests/Unit/FoundationSnapshotServiceTest.php
ahmido c6e7591d19 feat: add Intune RBAC inventory and backup support (#155)
## Summary
- add Intune RBAC role definitions and role assignments as foundation-backed inventory, backup, and versioned snapshot types
- add RBAC-specific normalization, coverage, permission-warning handling, and preview-only restore safety behavior across existing Filament and service surfaces
- add spec 127 artifacts, contracts, audits, and focused regression coverage for inventory, backup, versioning, verification, and authorization behavior

## Testing
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Feature/Inventory/InventorySyncServiceTest.php tests/Feature/Filament/InventoryCoverageTableTest.php tests/Feature/FoundationBackupTest.php tests/Feature/Filament/RestoreExecutionTest.php tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php tests/Unit/GraphContractRegistryTest.php tests/Unit/FoundationSnapshotServiceTest.php tests/Feature/Verification/IntuneRbacPermissionCoverageTest.php tests/Unit/IntuneRoleDefinitionNormalizerTest.php tests/Unit/IntuneRoleAssignmentNormalizerTest.php`

## Notes
- tasks in `specs/127-rbac-inventory-backup/tasks.md` are complete except `T041`, which is the documented manual QA validation step

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #155
2026-03-09 10:40:51 +00:00

194 lines
7.3 KiB
PHP

<?php
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\FoundationSnapshotService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
class FoundationSnapshotGraphClient 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('returns a failure when the foundation contract is missing', function () {
$tenant = Tenant::factory()->create();
ensureDefaultProviderConnection($tenant, 'microsoft');
$client = new FoundationSnapshotGraphClient([]);
app()->instance(GraphClientInterface::class, $client);
$service = app(FoundationSnapshotService::class);
$result = $service->fetchAll($tenant, 'unknownFoundation');
expect($result['items'])->toBeEmpty();
expect($result['failures'])->toHaveCount(1);
expect($result['failures'][0]['foundation_type'])->toBe('unknownFoundation');
});
it('hydrates foundation snapshots across pages with metadata', function () {
config()->set('graph_contracts.types.assignmentFilter', [
'resource' => 'deviceManagement/assignmentFilters',
'allowed_select' => ['id', 'displayName'],
]);
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-123',
'app_client_id' => 'client-123',
'app_client_secret' => 'secret-123',
]);
ensureDefaultProviderConnection($tenant, 'microsoft');
$responses = [
new GraphResponse(
success: true,
data: [
'value' => [
['id' => 'filter-1', 'displayName' => 'Filter One'],
],
'@odata.nextLink' => 'https://graph.microsoft.com/beta/deviceManagement/assignmentFilters?$skiptoken=abc',
],
),
new GraphResponse(
success: true,
data: [
'value' => [
['id' => 'filter-2', 'displayName' => 'Filter Two'],
],
],
),
];
$client = new FoundationSnapshotGraphClient($responses);
app()->instance(GraphClientInterface::class, $client);
$service = app(FoundationSnapshotService::class);
$result = $service->fetchAll($tenant, 'assignmentFilter');
expect($result['items'])->toHaveCount(2);
expect($result['items'][0]['source_id'])->toBe('filter-1');
expect($result['items'][0]['metadata']['displayName'])->toBe('Filter One');
expect($result['items'][0]['metadata']['kind'])->toBe('assignmentFilter');
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[1]['path'])->toBe('deviceManagement/assignmentFilters?$skiptoken=abc');
expect($client->requests[1]['options']['query'])->toBe([]);
});
it('hydrates RBAC assignment snapshots with contract-driven expand across pages', function () {
config()->set('graph_contracts.types.intuneRoleAssignment', [
'resource' => 'deviceManagement/roleAssignments',
'allowed_select' => ['id', 'displayName', 'description', 'members', 'scopeMembers', 'resourceScopes', 'scopeType'],
'allowed_expand' => ['roleDefinition($select=id,displayName,description,isBuiltIn)'],
]);
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-rbac-123',
'app_client_id' => 'client-rbac-123',
'app_client_secret' => 'secret-rbac-123',
]);
ensureDefaultProviderConnection($tenant, 'microsoft');
$responses = [
new GraphResponse(
success: true,
data: [
'value' => [
[
'id' => 'assignment-1',
'displayName' => 'Help Desk Assignment',
'members' => ['group-1'],
'scopeMembers' => ['scope-member-1'],
'resourceScopes' => ['scope-1'],
'roleDefinition' => [
'id' => 'role-definition-1',
'displayName' => 'Help Desk Operator',
],
],
],
'@odata.nextLink' => 'https://graph.microsoft.com/beta/deviceManagement/roleAssignments?$skiptoken=rbac',
],
),
new GraphResponse(
success: true,
data: [
'value' => [
[
'id' => 'assignment-2',
'displayName' => 'Operations Assignment',
'members' => ['group-2'],
'resourceScopes' => ['scope-2'],
],
],
],
),
];
$client = new FoundationSnapshotGraphClient($responses);
app()->instance(GraphClientInterface::class, $client);
$service = app(FoundationSnapshotService::class);
$result = $service->fetchAll($tenant, 'intuneRoleAssignment');
expect($result['items'])->toHaveCount(2);
expect($result['items'][0]['source_id'])->toBe('assignment-1');
expect($result['items'][0]['metadata']['kind'])->toBe('intuneRoleAssignment');
expect($result['items'][0]['metadata']['graph']['resource'])->toBe('deviceManagement/roleAssignments');
expect($result['items'][0]['payload']['roleDefinition']['displayName'])->toBe('Help Desk Operator');
expect($result['items'][1]['source_id'])->toBe('assignment-2');
expect($client->requests[0]['path'])->toBe('deviceManagement/roleAssignments');
expect($client->requests[0]['options']['query']['$select'])->toBe('id,displayName,description,members,scopeMembers,resourceScopes,scopeType');
expect($client->requests[0]['options']['query']['$expand'])->toBe('roleDefinition($select=id,displayName,description,isBuiltIn)');
expect($client->requests[1]['path'])->toBe('deviceManagement/roleAssignments?$skiptoken=rbac');
expect($client->requests[1]['options']['query'])->toBe([]);
});