create([ 'external_id' => 'tenant-assist-blocked-a', 'name' => 'Blocked Tenant', ]); $permissionsBuilder = Mockery::mock(TenantRequiredPermissionsViewModelBuilder::class); $permissionsBuilder ->shouldReceive('build') ->twice() ->withArgs(function (Tenant $passedTenant, array $filters) use ($tenant): bool { return (int) $passedTenant->getKey() === (int) $tenant->getKey() && ($filters['status'] ?? null) === 'all' && ($filters['type'] ?? null) === 'all' && ($filters['features'] ?? null) === [] && ($filters['search'] ?? null) === ''; }) ->andReturn([ 'tenant' => [ 'id' => (int) $tenant->getKey(), 'external_id' => (string) $tenant->external_id, 'name' => (string) $tenant->name, ], 'overview' => [ 'overall' => VerificationReportOverall::Blocked->value, 'counts' => [ 'missing_application' => 1, 'missing_delegated' => 1, 'present' => 3, 'error' => 0, ], 'freshness' => [ 'last_refreshed_at' => now()->toIso8601String(), 'is_stale' => false, ], ], 'permissions' => [ [ 'key' => 'DeviceManagementConfiguration.Read.All', 'type' => 'application', 'description' => 'Read device configurations', 'features' => ['inventory'], 'status' => 'missing', 'details' => null, ], [ 'key' => 'User.Read.All', 'type' => 'delegated', 'description' => 'Read users', 'features' => ['directory'], 'status' => 'missing', 'details' => null, ], [ 'key' => 'Group.Read.All', 'type' => 'application', 'description' => 'Read groups', 'features' => ['directory'], 'status' => 'granted', 'details' => null, ], ], 'copy' => [ 'application' => "DeviceManagementConfiguration.Read.All\nPolicy.Read.All", 'delegated' => 'User.Read.All', ], ]); $builder = new VerificationAssistViewModelBuilder( $permissionsBuilder, app(ProviderNextStepsRegistry::class), ); $report = VerificationReportWriter::build('provider.connection.check', [ [ 'key' => 'permissions.admin_consent', 'title' => 'Required application permissions', 'status' => 'fail', 'severity' => 'critical', 'blocking' => true, 'reason_code' => ProviderReasonCodes::ProviderConsentMissing, 'message' => 'Grant admin consent before continuing.', 'evidence' => [], 'next_steps' => [], ], ]); $visibility = $builder->visibility($tenant, $report); $assist = $builder->build( tenant: $tenant, verificationReport: $report, verificationStatus: 'blocked', isVerificationStale: false, staleReason: null, canAccessProviderConnectionDiagnostics: true, ); expect($visibility)->toMatchArray([ 'is_visible' => true, 'reason' => 'permission_blocked', ]); expect($assist)->toMatchArray([ 'tenant' => [ 'id' => (int) $tenant->getKey(), 'external_id' => (string) $tenant->external_id, 'name' => (string) $tenant->name, ], 'verification' => [ 'overall' => VerificationReportOverall::Blocked->value, 'status' => 'blocked', 'is_stale' => false, 'stale_reason' => null, ], 'copy' => [ 'application' => "DeviceManagementConfiguration.Read.All\nPolicy.Read.All", 'delegated' => 'User.Read.All', ], ]); expect($assist['overview'])->toMatchArray([ 'overall' => VerificationReportOverall::Blocked->value, 'counts' => [ 'missing_application' => 1, 'missing_delegated' => 1, 'present' => 3, 'error' => 0, ], ]); expect($assist['overview']['freshness']['is_stale'])->toBeFalse(); expect($assist['missing_permissions']['application'])->toHaveCount(1) ->and($assist['missing_permissions']['delegated'])->toHaveCount(1) ->and($assist['actions']['full_page'])->toMatchArray([ 'label' => 'Open full page', 'url' => RequiredPermissionsLinks::requiredPermissions($tenant), 'opens_in_new_tab' => true, 'available' => true, 'is_secondary' => true, ]) ->and($assist['actions']['copy_application'])->toMatchArray([ 'label' => 'Copy missing application permissions', 'available' => true, ]) ->and($assist['actions']['copy_delegated'])->toMatchArray([ 'label' => 'Copy missing delegated permissions', 'available' => true, ]) ->and($assist['actions']['grant_admin_consent'])->toMatchArray([ 'label' => 'Grant admin consent', 'url' => RequiredPermissionsLinks::adminConsentGuideUrl(), 'opens_in_new_tab' => true, 'available' => true, ]) ->and($assist['fallback'])->toMatchArray([ 'has_incomplete_detail' => false, 'message' => null, ]); expect($assist['actions']['manage_provider_connection'])->toMatchArray([ 'label' => 'Manage Provider Connections', 'opens_in_new_tab' => true, 'available' => true, ]); expect((string) $assist['actions']['manage_provider_connection']['url'])->toContain('/admin/provider-connections'); }); it('hides the assist when the verification report is ready and permission diagnostics are healthy', function (): void { $tenant = Tenant::factory()->create([ 'external_id' => 'tenant-assist-ready-a', ]); $permissionsBuilder = Mockery::mock(TenantRequiredPermissionsViewModelBuilder::class); $permissionsBuilder ->shouldReceive('build') ->once() ->andReturn([ 'tenant' => [ 'id' => (int) $tenant->getKey(), 'external_id' => (string) $tenant->external_id, 'name' => (string) $tenant->name, ], 'overview' => [ 'overall' => VerificationReportOverall::Ready->value, 'counts' => [ 'missing_application' => 0, 'missing_delegated' => 0, 'present' => 15, 'error' => 0, ], 'freshness' => [ 'last_refreshed_at' => now()->toIso8601String(), 'is_stale' => false, ], ], 'permissions' => [], 'copy' => [ 'application' => '', 'delegated' => '', ], ]); $builder = new VerificationAssistViewModelBuilder( $permissionsBuilder, app(ProviderNextStepsRegistry::class), ); $report = VerificationReportWriter::build('provider.connection.check', [ [ 'key' => 'provider.connection.check', 'title' => 'Provider connection check', 'status' => 'pass', 'severity' => 'info', 'blocking' => false, 'reason_code' => 'ok', 'message' => 'Connection is healthy.', 'evidence' => [], 'next_steps' => [], ], ]); expect($builder->visibility($tenant, $report))->toMatchArray([ 'is_visible' => false, 'reason' => 'hidden_ready', ]); }); it('builds degraded fallback messaging when permission detail is stale or incomplete', function (): void { $tenant = Tenant::factory()->create([ 'external_id' => 'tenant-assist-degraded-a', ]); $staleAt = CarbonImmutable::now()->subDays(45)->toIso8601String(); $permissionsBuilder = Mockery::mock(TenantRequiredPermissionsViewModelBuilder::class); $permissionsBuilder ->shouldReceive('build') ->twice() ->andReturn([ 'tenant' => [ 'id' => (int) $tenant->getKey(), 'external_id' => (string) $tenant->external_id, 'name' => (string) $tenant->name, ], 'overview' => [ 'overall' => VerificationReportOverall::NeedsAttention->value, 'counts' => [ 'missing_application' => 0, 'missing_delegated' => 0, 'present' => 14, 'error' => 1, ], 'freshness' => [ 'last_refreshed_at' => $staleAt, 'is_stale' => true, ], ], 'permissions' => [ [ 'key' => 'DeviceManagementManagedDevices.Read.All', 'type' => 'application', 'description' => 'Stored row is incomplete', 'features' => ['inventory'], 'status' => 'error', 'details' => ['reason_code' => 'permission_denied'], ], ], 'copy' => [ 'application' => '', 'delegated' => '', ], ]); $builder = new VerificationAssistViewModelBuilder( $permissionsBuilder, app(ProviderNextStepsRegistry::class), ); $report = VerificationReportWriter::build('provider.connection.check', [ [ 'key' => 'permissions.admin_consent', 'title' => 'Required application permissions', 'status' => 'warn', 'severity' => 'medium', 'blocking' => false, 'reason_code' => ProviderReasonCodes::ProviderPermissionRefreshFailed, 'message' => 'Stored permission data needs review.', 'evidence' => [], 'next_steps' => [], ], ]); $visibility = $builder->visibility($tenant, $report); $assist = $builder->build( tenant: $tenant, verificationReport: $report, verificationStatus: 'needs_attention', isVerificationStale: true, staleReason: 'The selected provider connection has changed since this verification run.', canAccessProviderConnectionDiagnostics: false, ); expect($visibility)->toMatchArray([ 'is_visible' => true, 'reason' => 'permission_attention', ]); expect($assist['verification'])->toMatchArray([ 'overall' => VerificationReportOverall::NeedsAttention->value, 'status' => 'needs_attention', 'is_stale' => true, 'stale_reason' => 'The selected provider connection has changed since this verification run.', ]); expect($assist['actions']['copy_application']['available'])->toBeFalse() ->and($assist['actions']['copy_delegated']['available'])->toBeFalse() ->and($assist['actions']['grant_admin_consent']['available'])->toBeFalse() ->and($assist['actions']['manage_provider_connection']['available'])->toBeFalse() ->and($assist['fallback']['has_incomplete_detail'])->toBeTrue() ->and($assist['fallback']['message'])->not->toBeNull(); });