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'); });