TenantAtlas/tests/Unit/RbacOnboardingServiceTest.php

469 lines
22 KiB
PHP

<?php
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\RbacOnboardingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
config()->set('tenantpilot.features.conditional_access', false);
});
function fakeTenant(): Tenant
{
return Tenant::create([
'tenant_id' => '00000000-0000-0000-0000-000000000000',
'name' => 'Tenant One',
'app_client_id' => 'app-client-123',
'app_client_secret' => 'secret',
'is_current' => true,
]);
}
it('creates group membership and role assignment', function () {
$tenant = fakeTenant();
$graph = \Mockery::mock(GraphClientInterface::class);
$graph->shouldReceive('request')->andReturnUsing(function (string $method, string $path, array $options = []) {
return match ([$method, $path]) {
['GET', 'servicePrincipals'] => new GraphResponse(true, ['value' => [['id' => 'sp-1']]]),
['GET', 'groups'] => new GraphResponse(true, ['value' => []]),
['POST', 'groups'] => new GraphResponse(true, ['id' => 'group-1']),
['POST', 'groups/group-1/members/$ref'] => new GraphResponse(true, []),
['GET', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['value' => [
['id' => 'assign-1', 'resourceScopes' => ['/'], 'roleDefinition' => ['id' => 'role-1']],
]]),
['GET', 'deviceManagement/roleAssignments/assign-1'] => new GraphResponse(true, ['id' => 'assign-1', 'members' => ['group-1'], 'resourceScopes' => ['/']]),
['POST', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['id' => 'assign-1']),
['GET', 'deviceManagement/deviceConfigurations?$top=1'] => new GraphResponse(true, ['value' => []]),
['GET', 'deviceManagement/deviceCompliancePolicies?$top=1'] => new GraphResponse(true, ['value' => []]),
default => throw new RuntimeException("Unexpected Graph request: {$method} {$path}"),
};
});
app()->instance(GraphClientInterface::class, $graph);
$service = app(RbacOnboardingService::class);
$result = $service->run($tenant, [
'group_mode' => 'create',
'role_definition_id' => 'role-1',
'role_display_name' => 'Policy and Profile Manager',
'scope' => 'all_devices',
], null, 'access-token');
expect($result['status'])->toBe('ok');
expect($result['group_id'])->toBe('group-1');
expect($result['role_assignment_id'])->toBe('assign-1');
$tenant->refresh();
expect($tenant->rbac_group_id)->toBe('group-1');
expect($tenant->rbac_role_assignment_id)->toBe('assign-1');
expect($tenant->rbac_role_definition_id)->toBe('role-1');
expect($tenant->rbac_role_display_name)->toBe('Policy and Profile Manager');
});
it('is idempotent when group membership already exists', function () {
$tenant = fakeTenant();
$graph = \Mockery::mock(GraphClientInterface::class);
$graph->shouldReceive('request')->andReturnUsing(function (string $method, string $path) {
return match ([$method, $path]) {
['GET', 'servicePrincipals'] => new GraphResponse(true, ['value' => [['id' => 'sp-1']]]),
['POST', 'groups/group-1/members/$ref'] => new GraphResponse(false, [], 400, [['message' => 'One or more added object references already exist']]),
['GET', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['value' => [
['id' => 'assign-1', 'resourceScopes' => ['/'], 'roleDefinition' => ['id' => 'role-1']],
]]),
['GET', 'deviceManagement/roleAssignments/assign-1'] => new GraphResponse(true, ['id' => 'assign-1', 'members' => ['group-1'], 'resourceScopes' => ['/']]),
['POST', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['id' => 'assign-1']),
['GET', 'deviceManagement/deviceConfigurations?$top=1'] => new GraphResponse(true, ['value' => []]),
['GET', 'deviceManagement/deviceCompliancePolicies?$top=1'] => new GraphResponse(true, ['value' => []]),
default => new GraphResponse(true, ['value' => []]),
};
});
app()->instance(GraphClientInterface::class, $graph);
$service = app(RbacOnboardingService::class);
$result = $service->run($tenant, [
'group_mode' => 'existing',
'existing_group_id' => 'group-1',
'role_definition_id' => 'role-1',
'role_display_name' => 'Policy and Profile Manager',
'scope' => 'all_devices',
], null, 'access-token');
expect($result['status'])->toBe('ok');
expect($result['group_id'])->toBe('group-1');
});
it('requires a role definition id', function () {
$tenant = fakeTenant();
$graph = \Mockery::mock(GraphClientInterface::class);
$graph->shouldReceive('request')->never();
app()->instance(GraphClientInterface::class, $graph);
$service = app(RbacOnboardingService::class);
$result = $service->run($tenant, [
'group_mode' => 'create',
'scope' => 'all_devices',
], null, 'access-token');
expect($result['status'])->toBe('error');
expect(strtolower($result['message']))->toContain('roledefinitionid');
});
it('is idempotent when membership add returns a request-exception style message', function () {
$tenant = fakeTenant();
$graph = \Mockery::mock(GraphClientInterface::class);
$message = 'HTTP request returned status code 400: {"error":{"code":"Request_BadRequest","message":"One or more added object references already exist for the following modified properties: \'members\'."}}';
$graph->shouldReceive('request')->andReturnUsing(function (string $method, string $path) use ($message) {
return match ([$method, $path]) {
['GET', 'servicePrincipals'] => new GraphResponse(true, ['value' => [['id' => 'sp-1']]]),
['POST', 'groups/group-1/members/$ref'] => new GraphResponse(false, [], 400, [$message]),
['GET', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['value' => [
['id' => 'assign-1', 'resourceScopes' => ['/'], 'roleDefinition' => ['id' => 'role-1']],
]]),
['GET', 'deviceManagement/roleAssignments/assign-1'] => new GraphResponse(true, ['id' => 'assign-1', 'members' => ['group-1'], 'resourceScopes' => ['/']]),
['POST', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['id' => 'assign-1']),
['GET', 'deviceManagement/deviceConfigurations?$top=1'] => new GraphResponse(true, ['value' => []]),
['GET', 'deviceManagement/deviceCompliancePolicies?$top=1'] => new GraphResponse(true, ['value' => []]),
default => new GraphResponse(true, ['value' => []]),
};
});
app()->instance(GraphClientInterface::class, $graph);
$service = app(RbacOnboardingService::class);
$result = $service->run($tenant, [
'group_mode' => 'existing',
'existing_group_id' => 'group-1',
'role_definition_id' => 'role-1',
'role_display_name' => 'Policy and Profile Manager',
'scope' => 'all_devices',
], null, 'access-token');
expect($result['status'])->toBe('ok');
expect($result['group_id'])->toBe('group-1');
});
it('is idempotent when group membership reference already exists', function () {
$tenant = fakeTenant();
$graph = \Mockery::mock(GraphClientInterface::class);
$groupId = 'group-1';
$servicePrincipalId = 'sp-1';
$error = [
'error' => [
'code' => 'Request_BadRequest',
'message' => 'One or more added object references already exist for the following modified properties: \'members\'.',
],
];
$graph->shouldReceive('request')->andReturnUsing(function (string $method, string $path, array $options = []) use ($groupId, $servicePrincipalId, $error) {
return match ([$method, $path]) {
['GET', 'servicePrincipals'] => new GraphResponse(true, ['value' => [['id' => $servicePrincipalId]]]),
['GET', 'groups'] => new GraphResponse(true, ['value' => []]),
['POST', 'groups'] => new GraphResponse(true, ['id' => $groupId]),
['POST', "groups/{$groupId}/members/\$ref"] => new GraphResponse(false, $error, 400),
['GET', 'deviceManagement/roleDefinitions'] => new GraphResponse(true, ['value' => [['id' => 'role-1', 'displayName' => 'Policy and Profile Manager']]]),
['GET', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['value' => []]),
['POST', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['id' => 'assign-1']),
['GET', 'deviceManagement/deviceConfigurations?$top=1'] => new GraphResponse(true, ['value' => []]),
['GET', 'deviceManagement/deviceCompliancePolicies?$top=1'] => new GraphResponse(true, ['value' => []]),
['GET', 'identity/conditionalAccess/policies?$top=1'] => new GraphResponse(true, ['value' => []]),
default => throw new RuntimeException("Unexpected Graph request: {$method} {$path}"),
};
});
app()->instance(GraphClientInterface::class, $graph);
$service = app(RbacOnboardingService::class);
$result = $service->run($tenant, [
'group_mode' => 'create',
'role_definition_id' => 'role-1',
'role_display_name' => 'Policy and Profile Manager',
'scope' => 'all_devices',
], null, 'access-token');
expect($result['status'])->toBe('ok');
expect($result['group_id'])->toBe('group-1');
expect($result['role_assignment_id'])->toBe('assign-1');
$tenant->refresh();
expect($tenant->rbac_group_id)->toBe('group-1');
expect($tenant->rbac_role_assignment_id)->toBe('assign-1');
});
it('surfaces membership errors with step, path, and status context', function () {
$tenant = fakeTenant();
$graph = \Mockery::mock(GraphClientInterface::class);
$graph->shouldReceive('request')->andReturn(
new GraphResponse(true, ['value' => [['id' => 'sp-1']]]), // service principal
new GraphResponse(true, ['value' => []]), // existing group lookup
new GraphResponse(true, ['id' => 'group-1']), // create group
new GraphResponse(false, ['error' => ['code' => 'Request_BadRequest', 'message' => 'Different failure']], 400), // add member fails
);
app()->instance(GraphClientInterface::class, $graph);
$service = app(RbacOnboardingService::class);
$result = $service->run($tenant, [
'group_mode' => 'create',
'role_definition_id' => 'role-1',
'role_display_name' => 'Policy and Profile Manager',
'scope' => 'all_devices',
], null, 'access-token');
expect($result['status'])->toBe('error');
expect($result['message'])->toContain('step=ensureGroupMembership');
expect($result['message'])->toContain('path=/groups/group-1/members/$ref');
expect($result['message'])->toContain('status=400');
});
it('continues when role assignment member fetch fails and creates new assignment', function () {
$tenant = fakeTenant();
$graph = \Mockery::mock(GraphClientInterface::class);
$graph->shouldReceive('request')->andReturnUsing(function (string $method, string $path) {
return match ([$method, $path]) {
['GET', 'servicePrincipals'] => new GraphResponse(true, ['value' => [['id' => 'sp-1']]]),
['GET', 'groups'] => new GraphResponse(true, ['value' => []]),
['POST', 'groups'] => new GraphResponse(true, ['id' => 'group-1']),
['POST', 'groups/group-1/members/$ref'] => new GraphResponse(true, []),
['GET', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['value' => [
['id' => 'assign-1', 'resourceScopes' => ['/'], 'roleDefinition' => ['id' => 'role-1']],
]]),
['GET', 'deviceManagement/roleAssignments/assign-1'] => new GraphResponse(false, [
'error' => ['code' => 'BadRequest', 'message' => 'Unsupported $expand for members'],
], 400),
['POST', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['id' => 'assign-2']),
default => new GraphResponse(true, ['value' => []]),
};
});
app()->instance(GraphClientInterface::class, $graph);
$service = app(RbacOnboardingService::class);
$result = $service->run($tenant, [
'group_mode' => 'create',
'role_definition_id' => 'role-1',
'role_display_name' => 'Policy and Profile Manager',
'scope' => 'all_devices',
], null, 'access-token');
expect($result['status'])->toBe('ok');
expect($result['role_assignment_id'])->toBe('assign-2');
});
it('surfaces role assignment create errors with status and message', function () {
$tenant = fakeTenant();
$graph = \Mockery::mock(GraphClientInterface::class);
$graph->shouldReceive('request')->andReturnUsing(function (string $method, string $path) {
return match ([$method, $path]) {
['GET', 'servicePrincipals'] => new GraphResponse(true, ['value' => [['id' => 'sp-1']]]),
['GET', 'groups'] => new GraphResponse(true, ['value' => []]),
['POST', 'groups'] => new GraphResponse(true, ['id' => 'group-1']),
['POST', 'groups/group-1/members/$ref'] => new GraphResponse(true, []),
['GET', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['value' => []]),
['POST', 'deviceManagement/roleAssignments'] => new GraphResponse(false, [
'error' => ['code' => 'BadRequest', 'message' => 'Invalid members@odata.bind'],
], 400),
default => new GraphResponse(true, ['value' => []]),
};
});
app()->instance(GraphClientInterface::class, $graph);
$service = app(RbacOnboardingService::class);
$result = $service->run($tenant, [
'group_mode' => 'create',
'role_definition_id' => 'role-1',
'role_display_name' => 'Policy and Profile Manager',
'scope' => 'all_devices',
], null, 'access-token');
expect($result['status'])->toBe('error');
expect($result['message'])->toContain('step=createRoleAssignment');
expect($result['message'])->toContain('status=400');
expect($result['message'])->toContain('Invalid members@odata.bind');
});
it('handles unsupported AAD account API gracefully with manual setup instructions', function () {
$tenant = fakeTenant();
$graph = \Mockery::mock(GraphClientInterface::class);
$graph->shouldReceive('request')->andReturnUsing(function (string $method, string $path) {
return match ([$method, $path]) {
['GET', 'servicePrincipals'] => new GraphResponse(true, ['value' => [['id' => 'sp-1']]]),
['GET', 'groups'] => new GraphResponse(true, ['value' => []]),
['POST', 'groups'] => new GraphResponse(true, ['id' => 'group-1', 'displayName' => 'TenantPilot-Intune-RBAC']),
['POST', 'groups/group-1/members/$ref'] => new GraphResponse(true, []),
['GET', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['value' => []]),
['POST', 'deviceManagement/roleAssignments'] => new GraphResponse(false, [
'error' => ['code' => 'BadRequest', 'message' => 'This API is not supported for AAD accounts (no addressUrl for Microsoft.Intune.Rbac,False).'],
], 400, [], [], ['request_id' => 'req-123', 'client_request_id' => 'client-456']),
['GET', 'deviceManagement/deviceConfigurations?$top=1'] => new GraphResponse(true, ['value' => []]),
['GET', 'deviceManagement/deviceCompliancePolicies?$top=1'] => new GraphResponse(true, ['value' => []]),
default => new GraphResponse(true, ['value' => []]),
};
});
app()->instance(GraphClientInterface::class, $graph);
$service = app(RbacOnboardingService::class);
$result = $service->run($tenant, [
'group_mode' => 'create',
'role_definition_id' => 'role-1',
'role_display_name' => 'Policy and Profile Manager',
'scope' => 'all_devices',
], null, 'access-token');
// Should be partial success, not complete failure
expect($result['status'])->toBe('manual_assignment_required');
expect($result['message'])->toContain('Intune RBAC API does not support automated role assignments');
expect($result['message'])->toContain('partially complete');
expect($result['message'])->toContain('TenantPilot-Intune-RBAC');
expect($result['message'])->toContain('group-1');
expect($result['message'])->toContain('request_id=req-123');
expect($result['message'])->toContain('client_request_id=client-456');
expect($result['group_id'])->toBe('group-1');
expect($result['role_assignment_id'])->toBeNull();
expect($result['warnings'])->toContain('manual_role_assignment_required');
expect($result['steps'])->toContain('role_assignment_manual_required');
});
it('lists role assignments without unsupported fields or expands', function () {
$tenant = fakeTenant();
$checked = false;
$graph = \Mockery::mock(GraphClientInterface::class);
$graph->shouldReceive('request')->andReturnUsing(function (string $method, string $path, array $options = []) use (&$checked) {
return match ([$method, $path]) {
['GET', 'servicePrincipals'] => new GraphResponse(true, ['value' => [['id' => 'sp-1']]]),
['GET', 'groups'] => new GraphResponse(true, ['value' => []]),
['POST', 'groups'] => new GraphResponse(true, ['id' => 'group-1']),
['POST', 'groups/group-1/members/$ref'] => new GraphResponse(true, []),
['GET', 'deviceManagement/roleAssignments'] => tap(new GraphResponse(true, ['value' => [[
'id' => 'assign-1',
'members' => ['group-1'],
'resourceScopes' => ['/'],
'roleDefinition' => ['id' => 'role-1', 'displayName' => 'Policy and Profile Manager'],
]]]), function () use ($options, &$checked) {
expect($options['query']['$select'] ?? null)->toBe('id,displayName,resourceScopes,members');
expect($options['query']['$expand'] ?? null)->toBe('roleDefinition($select=id,displayName)');
$checked = true;
}),
['POST', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['id' => 'assign-1']),
['GET', 'deviceManagement/deviceConfigurations?$top=1'] => new GraphResponse(true, ['value' => []]),
['GET', 'deviceManagement/deviceCompliancePolicies?$top=1'] => new GraphResponse(true, ['value' => []]),
default => throw new RuntimeException("Unexpected Graph request: {$method} {$path}"),
};
});
app()->instance(GraphClientInterface::class, $graph);
$service = app(RbacOnboardingService::class);
$result = $service->run($tenant, [
'group_mode' => 'create',
'role_definition_id' => 'role-1',
'role_display_name' => 'Policy and Profile Manager',
'scope' => 'all_devices',
], null, 'access-token');
expect($checked)->toBeTrue();
expect($result['status'])->toBe('ok');
expect($result['role_assignment_id'])->toBe('assign-1');
});
it('falls back when role assignment members are missing without crashing', function () {
$tenant = fakeTenant();
$graph = \Mockery::mock(GraphClientInterface::class);
$graph->shouldReceive('request')->andReturnUsing(function (string $method, string $path) {
return match ([$method, $path]) {
['GET', 'servicePrincipals'] => new GraphResponse(true, ['value' => [['id' => 'sp-1']]]),
['GET', 'groups'] => new GraphResponse(true, ['value' => []]),
['POST', 'groups'] => new GraphResponse(true, ['id' => 'group-1']),
['POST', 'groups/group-1/members/$ref'] => new GraphResponse(true, []),
['GET', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['value' => [[
'id' => 'assign-1',
'resourceScopes' => ['/'],
'roleDefinition' => ['id' => 'role-1'],
]]]),
['GET', 'deviceManagement/roleAssignments/assign-1'] => new GraphResponse(false, [], 400, [
['error' => ['code' => 'BadRequest', 'message' => 'expand not allowed']],
]),
['POST', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['id' => 'assign-2']),
['GET', 'deviceManagement/deviceConfigurations?$top=1'] => new GraphResponse(true, ['value' => []]),
['GET', 'deviceManagement/deviceCompliancePolicies?$top=1'] => new GraphResponse(true, ['value' => []]),
default => throw new RuntimeException("Unexpected Graph request: {$method} {$path}"),
};
});
app()->instance(GraphClientInterface::class, $graph);
$service = app(RbacOnboardingService::class);
$result = $service->run($tenant, [
'group_mode' => 'create',
'role_definition_id' => 'role-1',
'role_display_name' => 'Policy and Profile Manager',
'scope' => 'all_devices',
], null, 'access-token');
expect($result['status'])->toBe('ok');
expect($result['role_assignment_id'])->toBe('assign-2');
});
it('fails when service principal is missing', function () {
$tenant = fakeTenant();
$graph = \Mockery::mock(GraphClientInterface::class);
$graph->shouldReceive('request')->once()->andReturn(
new GraphResponse(true, ['value' => []])
);
app()->instance(GraphClientInterface::class, $graph);
$service = app(RbacOnboardingService::class);
$result = $service->run($tenant, [
'group_mode' => 'create',
'role_definition_id' => 'role-1',
'role_display_name' => 'Policy and Profile Manager',
'scope' => 'all_devices',
], null, 'access-token');
expect($result['status'])->toBe('error');
expect(strtolower($result['message']))->toContain('service principal');
});