TenantAtlas/tests/Feature/Verification/ProviderConnectionHealthCheckWritesReportTest.php
ahmido 05a604cfb6 Spec 076: Tenant Required Permissions (enterprise remediation UX) (#92)
Implements Spec 076 enterprise remediation UX for tenant required permissions.

Highlights
- Above-the-fold overview (impact + counts) with missing-first experience
- Feature-based grouping, filters/search, copy-to-clipboard for missing app/delegated permissions
- Tenant-scoped deny-as-not-found semantics; DB-only viewing
- Centralized badge semantics (no ad-hoc status mapping)

Testing
- Feature tests for default filters, grouping, copy output, and non-member 404 behavior.

Integration
- Adds deep links from verification checks to the Required permissions page.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #92
2026-02-05 22:08:51 +00:00

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