feat/004-assignments-scope-tags #4

Merged
ahmido merged 41 commits from feat/004-assignments-scope-tags into dev 2025-12-23 21:49:59 +00:00
4 changed files with 119 additions and 106 deletions
Showing only changes of commit 650d052516 - Show all commits

View File

@ -99,10 +99,10 @@
$this->mock(ScopeTagResolver::class, function (MockInterface $mock) {
$mock->shouldReceive('resolve')
->once()
->with(['0', '123'])
->with(['0', '123'], Mockery::type(Tenant::class))
->andReturn([
'0' => 'Default',
'123' => 'HR-Admins',
['id' => '0', 'displayName' => 'Default'],
['id' => '123', 'displayName' => 'HR-Admins'],
]);
});
@ -174,7 +174,7 @@
$this->mock(ScopeTagResolver::class, function (MockInterface $mock) {
$mock->shouldReceive('resolve')
->once()
->with(['0', '123'])
->with(['0', '123'], Mockery::type(Tenant::class))
->andReturn([
['id' => '0', 'displayName' => 'Default'],
['id' => '123', 'displayName' => 'HR-Admins'],
@ -240,7 +240,7 @@
$this->mock(ScopeTagResolver::class, function (MockInterface $mock) {
$mock->shouldReceive('resolve')
->once()
->with(['0', '123'])
->with(['0', '123'], Mockery::type(Tenant::class))
->andReturn([
['id' => '0', 'displayName' => 'Default'],
['id' => '123', 'displayName' => 'HR-Admins'],
@ -321,7 +321,7 @@
$this->mock(ScopeTagResolver::class, function (MockInterface $mock) {
$mock->shouldReceive('resolve')
->once()
->with(['0', '123'])
->with(['0', '123'], Mockery::type(Tenant::class))
->andReturn([
['id' => '0', 'displayName' => 'Default'],
['id' => '123', 'displayName' => 'HR-Admins'],

View File

@ -6,12 +6,20 @@
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Graph\ScopeTagResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery\MockInterface;
uses(RefreshDatabase::class);
test('backup creation captures snapshots and audit log', function () {
// Mock ScopeTagResolver
$this->mock(ScopeTagResolver::class, function (MockInterface $mock) {
$mock->shouldReceive('resolve')
->andReturn([]);
});
app()->bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface
{
public function listPolicies(string $policyType, array $options = []): GraphResponse

View File

@ -83,7 +83,7 @@ public function request(string $method, string $path, array $options = []): Grap
$this->assertDatabaseHas('tenant_permissions', [
'tenant_id' => $tenant->id,
'status' => 'ok',
'status' => 'granted',
]);
});

View File

@ -1,7 +1,8 @@
<?php
use App\Services\Graph\GraphException;
use App\Models\Tenant;
use App\Services\Graph\GraphLogger;
use App\Services\Graph\GraphResponse;
use App\Services\Graph\MicrosoftGraphClient;
use App\Services\Graph\ScopeTagResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -12,126 +13,130 @@
beforeEach(function () {
Cache::flush();
$this->graphClient = Mockery::mock(MicrosoftGraphClient::class);
$this->logger = Mockery::mock(GraphLogger::class);
$this->resolver = new ScopeTagResolver($this->graphClient, $this->logger);
});
test('resolves scope tags', function () {
$scopeTagIds = ['0', '123', '456'];
$allScopeTags = [
test('resolves scope tag IDs to objects with id and displayName', function () {
$tenant = Tenant::factory()->create();
$mockGraphClient = Mockery::mock(MicrosoftGraphClient::class);
$mockGraphClient->shouldReceive('request')
->with('GET', '/deviceManagement/roleScopeTags', Mockery::on(function ($options) use ($tenant) {
return $options['query']['$select'] === 'id,displayName'
&& $options['tenant'] === $tenant->external_id
&& $options['client_id'] === $tenant->app_client_id
&& $options['client_secret'] === $tenant->app_client_secret;
}))
->once()
->andReturn(new GraphResponse(
success: true,
data: [
'value' => [
['id' => '0', 'displayName' => 'Default'],
['id' => '1', 'displayName' => 'Verbund-1'],
['id' => '2', 'displayName' => 'Verbund-2'],
],
]
));
$mockLogger = Mockery::mock(GraphLogger::class);
$resolver = new ScopeTagResolver($mockGraphClient, $mockLogger);
$result = $resolver->resolve(['0', '1', '2'], $tenant);
expect($result)->toBe([
['id' => '0', 'displayName' => 'Default'],
['id' => '123', 'displayName' => 'HR-Admins'],
['id' => '456', 'displayName' => 'Finance-Admins'],
['id' => '789', 'displayName' => 'IT-Admins'],
];
$this->graphClient
->shouldReceive('get')
->once()
->with('/deviceManagement/roleScopeTags', null, ['$select' => 'id,displayName'])
->andReturn(['value' => $allScopeTags]);
$this->logger
->shouldReceive('logDebug')
->once()
->with('Fetched scope tags', ['count' => 4]);
$result = $this->resolver->resolve($scopeTagIds);
expect($result)->toHaveCount(3)
->and(array_column($result, 'id'))->toContain('0', '123', '456')
->and(array_column($result, 'id'))->not->toContain('789');
['id' => '1', 'displayName' => 'Verbund-1'],
['id' => '2', 'displayName' => 'Verbund-2'],
]);
});
test('caches results', function () {
$scopeTagIds = ['0', '123'];
$allScopeTags = [
['id' => '0', 'displayName' => 'Default'],
['id' => '123', 'displayName' => 'HR-Admins'],
];
test('caches scope tag objects for 1 hour', function () {
$tenant = Tenant::factory()->create();
$mockGraphClient = Mockery::mock(MicrosoftGraphClient::class);
$mockGraphClient->shouldReceive('request')
->once() // Only called once due to caching
->andReturn(new GraphResponse(
success: true,
data: [
'value' => [
['id' => '0', 'displayName' => 'Default'],
],
]
));
// First call - should hit Graph API
$this->graphClient
->shouldReceive('get')
->once()
->andReturn(['value' => $allScopeTags]);
$mockLogger = Mockery::mock(GraphLogger::class);
$resolver = new ScopeTagResolver($mockGraphClient, $mockLogger);
// First call - fetches from API
$result1 = $resolver->resolve(['0'], $tenant);
// Second call - should use cache
$result2 = $resolver->resolve(['0'], $tenant);
$this->logger
->shouldReceive('logDebug')
->once();
$result1 = $this->resolver->resolve($scopeTagIds);
// Second call with different IDs - should use cached data (no new Graph call)
$result2 = $this->resolver->resolve(['0']);
expect($result1)->toHaveCount(2)
->and($result2)->toHaveCount(1)
->and($result2[0]['id'])->toBe('0');
expect($result1)->toBe([['id' => '0', 'displayName' => 'Default']]);
expect($result2)->toBe([['id' => '0', 'displayName' => 'Default']]);
});
test('returns empty array for empty input', function () {
$result = $this->resolver->resolve([]);
$tenant = Tenant::factory()->create();
$mockGraphClient = Mockery::mock(MicrosoftGraphClient::class);
$mockLogger = Mockery::mock(GraphLogger::class);
$resolver = new ScopeTagResolver($mockGraphClient, $mockLogger);
$result = $resolver->resolve([], $tenant);
expect($result)->toBe([]);
});
test('handles graph exception gracefully', function () {
$scopeTagIds = ['0', '123'];
$this->graphClient
->shouldReceive('get')
test('handles 403 forbidden gracefully', function () {
$tenant = Tenant::factory()->create();
$mockGraphClient = Mockery::mock(MicrosoftGraphClient::class);
$mockGraphClient->shouldReceive('request')
->once()
->andThrow(new GraphException('Graph API error', 500, ['request_id' => 'request-id-123']));
->andReturn(new GraphResponse(
success: false,
status: 403,
data: []
));
$this->logger
->shouldReceive('logWarning')
->once()
->with('Failed to fetch scope tags', Mockery::on(function ($context) {
return isset($context['context']['request_id']);
}));
$result = $this->resolver->resolve($scopeTagIds);
$mockLogger = Mockery::mock(GraphLogger::class);
$resolver = new ScopeTagResolver($mockGraphClient, $mockLogger);
$result = $resolver->resolve(['0', '1'], $tenant);
// Should return empty array when 403
expect($result)->toBe([]);
});
test('filters correctly when scope tag not in cache', function () {
$scopeTagIds = ['999']; // ID that doesn't exist
$allScopeTags = [
['id' => '0', 'displayName' => 'Default'],
['id' => '123', 'displayName' => 'HR-Admins'],
];
$this->graphClient
->shouldReceive('get')
test('filters returned scope tags to requested IDs', function () {
$tenant = Tenant::factory()->create();
$mockGraphClient = Mockery::mock(MicrosoftGraphClient::class);
$mockGraphClient->shouldReceive('request')
->once()
->andReturn(['value' => $allScopeTags]);
->andReturn(new GraphResponse(
success: true,
data: [
'value' => [
['id' => '0', 'displayName' => 'Default'],
['id' => '1', 'displayName' => 'Verbund-1'],
['id' => '2', 'displayName' => 'Verbund-2'],
],
]
));
$this->logger
->shouldReceive('logDebug')
->once();
$mockLogger = Mockery::mock(GraphLogger::class);
$resolver = new ScopeTagResolver($mockGraphClient, $mockLogger);
// Request only IDs 0 and 2
$result = $resolver->resolve(['0', '2'], $tenant);
$result = $this->resolver->resolve($scopeTagIds);
expect($result)->toBeEmpty();
expect($result)->toHaveCount(2);
// Note: array_filter preserves keys, so result will be [0 => ..., 2 => ...]
expect($result[0])->toBe(['id' => '0', 'displayName' => 'Default']);
expect($result[2])->toBe(['id' => '2', 'displayName' => 'Verbund-2']);
});
test('handles response without value key', function () {
$scopeTagIds = ['0', '123'];
$this->graphClient
->shouldReceive('get')
->once()
->andReturn([]); // Missing 'value' key
$this->logger
->shouldReceive('logDebug')
->once()
->with('Fetched scope tags', ['count' => 0]);
$result = $this->resolver->resolve($scopeTagIds);
expect($result)->toBeEmpty();
});