373 lines
13 KiB
PHP
373 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Jobs\ProviderConnectionHealthCheckJob;
|
|
use App\Models\AuditLog;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\ProviderCredential;
|
|
use App\Models\TenantPermission;
|
|
use App\Services\Graph\GraphClientInterface;
|
|
use App\Services\Graph\GraphResponse;
|
|
use App\Services\OperationRunService;
|
|
use App\Services\Providers\MicrosoftProviderHealthCheck;
|
|
use App\Support\Audit\AuditActionId;
|
|
use App\Support\Verification\VerificationReportSchema;
|
|
|
|
it('writes a sanitized verification report for failed provider connection checks', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
|
|
|
$connection = ProviderConnection::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'provider' => 'microsoft',
|
|
'entra_tenant_id' => fake()->uuid(),
|
|
]);
|
|
|
|
ProviderCredential::factory()->create([
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'payload' => [
|
|
'tenant_id' => (string) $connection->entra_tenant_id,
|
|
'client_id' => fake()->uuid(),
|
|
'client_secret' => fake()->sha1(),
|
|
],
|
|
]);
|
|
|
|
$run = OperationRun::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'user_id' => (int) $user->getKey(),
|
|
'type' => 'provider.connection.check',
|
|
'status' => 'running',
|
|
'outcome' => 'pending',
|
|
'context' => [
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
],
|
|
]);
|
|
|
|
$this->mock(GraphClientInterface::class, function ($mock): void {
|
|
$mock->shouldReceive('getOrganization')
|
|
->once()
|
|
->andReturn(new GraphResponse(false, [], 401, ['Bearer super-secret-token']));
|
|
});
|
|
|
|
$job = new ProviderConnectionHealthCheckJob(
|
|
tenantId: (int) $tenant->getKey(),
|
|
userId: (int) $user->getKey(),
|
|
providerConnectionId: (int) $connection->getKey(),
|
|
operationRun: $run,
|
|
);
|
|
|
|
$job->handle(
|
|
healthCheck: app(MicrosoftProviderHealthCheck::class),
|
|
runs: app(OperationRunService::class),
|
|
);
|
|
|
|
$run = $run->fresh();
|
|
|
|
expect($run)->not->toBeNull();
|
|
expect($run->status)->toBe('completed');
|
|
expect($run->outcome)->toBe('failed');
|
|
|
|
$context = is_array($run->context) ? $run->context : [];
|
|
$report = $context['verification_report'] ?? null;
|
|
|
|
expect($report)->toBeArray();
|
|
expect(VerificationReportSchema::isValidReport($report))->toBeTrue();
|
|
expect(json_encode($report))->not->toContain('Bearer ');
|
|
expect($report['checks'][0]['reason_code'] ?? null)->toBe('authentication_failed');
|
|
|
|
foreach (($report['checks'] ?? []) as $check) {
|
|
expect($check)->toBeArray();
|
|
|
|
foreach (($check['evidence'] ?? []) as $pointer) {
|
|
expect($pointer)->toBeArray();
|
|
expect(array_keys($pointer))->toEqualCanonicalizing(['kind', 'value']);
|
|
}
|
|
}
|
|
|
|
$audit = AuditLog::query()
|
|
->where('workspace_id', (int) $tenant->workspace_id)
|
|
->where('action', AuditActionId::VerificationCompleted->value)
|
|
->latest('id')
|
|
->first();
|
|
|
|
expect($audit)->not->toBeNull();
|
|
expect($audit?->metadata)->toMatchArray([
|
|
'operation_run_id' => (int) $run->getKey(),
|
|
]);
|
|
});
|
|
|
|
it('writes a verification report for successful provider connection checks', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
|
|
|
$connection = ProviderConnection::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'provider' => 'microsoft',
|
|
'entra_tenant_id' => fake()->uuid(),
|
|
]);
|
|
|
|
ProviderCredential::factory()->create([
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'payload' => [
|
|
'tenant_id' => (string) $connection->entra_tenant_id,
|
|
'client_id' => fake()->uuid(),
|
|
'client_secret' => fake()->sha1(),
|
|
],
|
|
]);
|
|
|
|
$run = OperationRun::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'user_id' => (int) $user->getKey(),
|
|
'type' => 'provider.connection.check',
|
|
'status' => 'running',
|
|
'outcome' => 'pending',
|
|
'context' => [
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
],
|
|
]);
|
|
|
|
$this->mock(GraphClientInterface::class, function ($mock): void {
|
|
$mock->shouldReceive('getOrganization')
|
|
->once()
|
|
->andReturn(new GraphResponse(true, [
|
|
'id' => 'org_123',
|
|
'displayName' => 'Org 123',
|
|
], 200));
|
|
|
|
$required = collect(config('intune_permissions.permissions', []))
|
|
->filter(fn (mixed $row): bool => is_array($row))
|
|
->map(fn (array $row): string => (string) ($row['key'] ?? ''))
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
$mock->shouldReceive('getServicePrincipalPermissions')
|
|
->once()
|
|
->andReturn(new GraphResponse(true, [
|
|
'permissions' => $required,
|
|
], 200));
|
|
});
|
|
|
|
$job = new ProviderConnectionHealthCheckJob(
|
|
tenantId: (int) $tenant->getKey(),
|
|
userId: (int) $user->getKey(),
|
|
providerConnectionId: (int) $connection->getKey(),
|
|
operationRun: $run,
|
|
);
|
|
|
|
$job->handle(
|
|
healthCheck: app(MicrosoftProviderHealthCheck::class),
|
|
runs: app(OperationRunService::class),
|
|
);
|
|
|
|
$run = $run->fresh();
|
|
|
|
expect($run)->not->toBeNull();
|
|
expect($run->status)->toBe('completed');
|
|
expect($run->outcome)->toBe('succeeded');
|
|
|
|
$context = is_array($run->context) ? $run->context : [];
|
|
$report = $context['verification_report'] ?? null;
|
|
|
|
expect($report)->toBeArray();
|
|
expect(VerificationReportSchema::isValidReport($report))->toBeTrue();
|
|
|
|
$checks = is_array($report['checks'] ?? null) ? $report['checks'] : [];
|
|
$checkKeys = collect($checks)
|
|
->filter(fn ($check) => is_array($check))
|
|
->map(fn (array $check): string => (string) ($check['key'] ?? ''))
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
expect($checkKeys)->toContain('provider.connection.check');
|
|
expect($checkKeys)->toContain('permissions.admin_consent');
|
|
|
|
$counts = is_array($report['summary']['counts'] ?? null) ? $report['summary']['counts'] : [];
|
|
expect((int) ($counts['total'] ?? 0))->toBe(count($checks));
|
|
expect((int) ($counts['total'] ?? 0))->toBeLessThanOrEqual(7);
|
|
});
|
|
|
|
it('degrades permission clusters to warnings when live permissions refresh is throttled', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
|
|
|
$connection = ProviderConnection::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'provider' => 'microsoft',
|
|
'entra_tenant_id' => fake()->uuid(),
|
|
]);
|
|
|
|
ProviderCredential::factory()->create([
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'payload' => [
|
|
'tenant_id' => (string) $connection->entra_tenant_id,
|
|
'client_id' => fake()->uuid(),
|
|
'client_secret' => fake()->sha1(),
|
|
],
|
|
]);
|
|
|
|
$run = OperationRun::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'user_id' => (int) $user->getKey(),
|
|
'type' => 'provider.connection.check',
|
|
'status' => 'running',
|
|
'outcome' => 'pending',
|
|
'context' => [
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
],
|
|
]);
|
|
|
|
$this->mock(GraphClientInterface::class, function ($mock): void {
|
|
$mock->shouldReceive('getOrganization')
|
|
->once()
|
|
->andReturn(new GraphResponse(true, [
|
|
'id' => 'org_123',
|
|
'displayName' => 'Org 123',
|
|
], 200));
|
|
|
|
$mock->shouldReceive('getServicePrincipalPermissions')
|
|
->once()
|
|
->andReturn(new GraphResponse(false, [], 429, ['Too Many Requests']));
|
|
});
|
|
|
|
$job = new ProviderConnectionHealthCheckJob(
|
|
tenantId: (int) $tenant->getKey(),
|
|
userId: (int) $user->getKey(),
|
|
providerConnectionId: (int) $connection->getKey(),
|
|
operationRun: $run,
|
|
);
|
|
|
|
$job->handle(
|
|
healthCheck: app(MicrosoftProviderHealthCheck::class),
|
|
runs: app(OperationRunService::class),
|
|
);
|
|
|
|
$run = $run->fresh();
|
|
|
|
expect($run?->outcome)->toBe('succeeded');
|
|
|
|
$context = is_array($run?->context) ? $run->context : [];
|
|
$report = $context['verification_report'] ?? null;
|
|
|
|
expect($report)->toBeArray();
|
|
expect(VerificationReportSchema::isValidReport($report))->toBeTrue();
|
|
|
|
$checks = is_array($report['checks'] ?? null) ? $report['checks'] : [];
|
|
|
|
$adminConsentCheck = collect($checks)
|
|
->filter(fn (mixed $check): bool => is_array($check))
|
|
->firstWhere('key', 'permissions.admin_consent');
|
|
|
|
expect($adminConsentCheck)->toBeArray();
|
|
expect($adminConsentCheck['status'] ?? null)->toBe('warn');
|
|
expect($adminConsentCheck['blocking'] ?? null)->toBeFalse();
|
|
expect($adminConsentCheck['reason_code'] ?? null)->toBe('throttled');
|
|
expect((string) ($adminConsentCheck['message'] ?? ''))->toContain('Unable to refresh observed permissions inventory');
|
|
|
|
expect(TenantPermission::query()->where('tenant_id', (int) $tenant->getKey())->count())->toBe(0);
|
|
});
|
|
|
|
it('degrades permission clusters to warnings when live permissions refresh cannot map assignments', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
|
|
|
$connection = ProviderConnection::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'provider' => 'microsoft',
|
|
'entra_tenant_id' => fake()->uuid(),
|
|
]);
|
|
|
|
ProviderCredential::factory()->create([
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'payload' => [
|
|
'tenant_id' => (string) $connection->entra_tenant_id,
|
|
'client_id' => fake()->uuid(),
|
|
'client_secret' => fake()->sha1(),
|
|
],
|
|
]);
|
|
|
|
$run = OperationRun::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'user_id' => (int) $user->getKey(),
|
|
'type' => 'provider.connection.check',
|
|
'status' => 'running',
|
|
'outcome' => 'pending',
|
|
'context' => [
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
],
|
|
]);
|
|
|
|
$this->mock(GraphClientInterface::class, function ($mock): void {
|
|
$mock->shouldReceive('getOrganization')
|
|
->once()
|
|
->andReturn(new GraphResponse(true, [
|
|
'id' => 'org_123',
|
|
'displayName' => 'Org 123',
|
|
], 200));
|
|
|
|
$mock->shouldReceive('getServicePrincipalPermissions')
|
|
->twice()
|
|
->andReturn(new GraphResponse(true, [
|
|
'permissions' => [],
|
|
'diagnostics' => [
|
|
'assignments_total' => 1,
|
|
'mapped_total' => 0,
|
|
'graph_roles_total' => 1,
|
|
],
|
|
], 200));
|
|
});
|
|
|
|
$job = new ProviderConnectionHealthCheckJob(
|
|
tenantId: (int) $tenant->getKey(),
|
|
userId: (int) $user->getKey(),
|
|
providerConnectionId: (int) $connection->getKey(),
|
|
operationRun: $run,
|
|
);
|
|
|
|
$job->handle(
|
|
healthCheck: app(MicrosoftProviderHealthCheck::class),
|
|
runs: app(OperationRunService::class),
|
|
);
|
|
|
|
$credential = ProviderCredential::query()
|
|
->where('provider_connection_id', (int) $connection->getKey())
|
|
->first();
|
|
|
|
$payload = is_array($credential?->payload) ? $credential->payload : [];
|
|
|
|
$comparison = app(\App\Services\Intune\TenantPermissionService::class)->compare(
|
|
$tenant,
|
|
persist: true,
|
|
liveCheck: true,
|
|
useConfiguredStub: false,
|
|
graphOptions: [
|
|
'tenant' => (string) $connection->entra_tenant_id,
|
|
'client_id' => (string) ($payload['client_id'] ?? ''),
|
|
'client_secret' => (string) ($payload['client_secret'] ?? ''),
|
|
],
|
|
);
|
|
|
|
expect($comparison['live_check']['reason_code'] ?? null)->toBe('permission_mapping_failed');
|
|
|
|
$run = $run->fresh();
|
|
|
|
$context = is_array($run?->context) ? $run->context : [];
|
|
$report = $context['verification_report'] ?? null;
|
|
|
|
expect($report)->toBeArray();
|
|
expect(VerificationReportSchema::isValidReport($report))->toBeTrue();
|
|
|
|
$checks = is_array($report['checks'] ?? null) ? $report['checks'] : [];
|
|
|
|
$adminConsentCheck = collect($checks)
|
|
->filter(fn (mixed $check): bool => is_array($check))
|
|
->firstWhere('key', 'permissions.admin_consent');
|
|
|
|
expect($adminConsentCheck)->toBeArray();
|
|
expect($adminConsentCheck['status'] ?? null)->toBe('warn');
|
|
expect($adminConsentCheck['blocking'] ?? null)->toBeFalse();
|
|
expect($adminConsentCheck['reason_code'] ?? null)->toBeIn(['permission_mapping_failed', 'unknown_error']);
|
|
expect((string) ($adminConsentCheck['message'] ?? ''))->toContain('Unable to refresh observed permissions inventory');
|
|
|
|
expect(TenantPermission::query()->where('tenant_id', (int) $tenant->getKey())->count())->toBe(0);
|
|
});
|