create(['status' => 'active']); [$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner', workspaceRole: 'readonly'); Tenant::factory()->create([ 'status' => 'active', 'workspace_id' => (int) $tenantA->workspace_id, 'name' => 'Inaccessible Tenant', ]); $workspace = $tenantA->workspace()->firstOrFail(); $overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user); $quickActionKeys = collect($overview['quick_actions'])->pluck('key')->all(); expect($overview['accessible_tenant_count'])->toBe(1) ->and($quickActionKeys)->toContain('switch_workspace') ->and($quickActionKeys)->not->toContain('manage_workspaces'); }); it('keeps governance attention visible but non-clickable when the tenant membership does not grant drill-through capability', function (): void { $tenant = Tenant::factory()->create(['status' => 'active']); [$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly'); workspaceOverviewSeedQuietTenantTruth($tenant); $backupSet = workspaceOverviewSeedHealthyBackup($tenant, [ 'completed_at' => now()->subMinutes(10), ]); workspaceOverviewSeedRestoreHistory($tenant, $backupSet, 'completed'); \App\Models\Finding::factory()->for($tenant)->create([ 'workspace_id' => (int) $tenant->workspace_id, 'status' => \App\Models\Finding::STATUS_TRIAGED, 'due_at' => now()->subDay(), ]); mock(CapabilityResolver::class, function ($mock) use ($tenant): void { $mock->shouldReceive('primeMemberships')->once(); $mock->shouldReceive('can') ->andReturnUsing(static function (\App\Models\User $user, Tenant $resolvedTenant, string $capability) use ($tenant): bool { expect((int) $resolvedTenant->getKey())->toBe((int) $tenant->getKey()); return match ($capability) { Capabilities::TENANT_VIEW, Capabilities::TENANT_FINDINGS_VIEW => false, default => false, }; }); }); $workspace = $tenant->workspace()->firstOrFail(); $overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user); $item = collect($overview['attention_items'])->firstWhere('key', 'tenant_overdue_findings'); expect($item['action_disabled'])->toBeTrue() ->and($item['destination']['kind'])->toBe('tenant_findings') ->and($item['helper_text'])->not->toBeNull(); }); it('omits hidden-tenant backup and recovery issues from workspace counts and calmness claims', function (): void { CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC')); $visibleTenant = Tenant::factory()->create(['status' => 'active']); [$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'owner', workspaceRole: 'readonly'); workspaceOverviewSeedQuietTenantTruth($visibleTenant); $visibleBackup = workspaceOverviewSeedHealthyBackup($visibleTenant, [ 'completed_at' => now()->subMinutes(10), ]); workspaceOverviewSeedRestoreHistory($visibleTenant, $visibleBackup, 'completed'); $hiddenBackupTenant = Tenant::factory()->create([ 'status' => 'active', 'workspace_id' => (int) $visibleTenant->workspace_id, 'name' => 'Hidden Backup Tenant', ]); workspaceOverviewSeedQuietTenantTruth($hiddenBackupTenant); $hiddenRecoveryTenant = Tenant::factory()->create([ 'status' => 'active', 'workspace_id' => (int) $visibleTenant->workspace_id, 'name' => 'Hidden Recovery Tenant', ]); workspaceOverviewSeedQuietTenantTruth($hiddenRecoveryTenant); $hiddenRecoveryBackup = workspaceOverviewSeedHealthyBackup($hiddenRecoveryTenant, [ 'completed_at' => now()->subMinutes(20), ]); workspaceOverviewSeedRestoreHistory($hiddenRecoveryTenant, $hiddenRecoveryBackup, 'follow_up'); $workspace = $visibleTenant->workspace()->firstOrFail(); $overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user); $metrics = collect($overview['summary_metrics'])->keyBy('key'); expect($metrics->get('backup_attention_tenants')['value'])->toBe(0) ->and($metrics->get('recovery_attention_tenants')['value'])->toBe(0) ->and($overview['attention_items'])->toBe([]) ->and($overview['calmness']['is_calm'])->toBeTrue() ->and($overview['calmness']['body'])->toContain('visible workspace') ->and(collect($overview['attention_items'])->pluck('tenant_label')->all()) ->not->toContain('Hidden Backup Tenant', 'Hidden Recovery Tenant'); }); it('keeps backup and recovery items tenant-safe when the tenant dashboard remains membership-accessible', function (): void { CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC')); $backupTenant = Tenant::factory()->create(['status' => 'active']); [$user, $backupTenant] = createUserWithTenant($backupTenant, role: 'readonly', workspaceRole: 'readonly'); workspaceOverviewSeedQuietTenantTruth($backupTenant); $recoveryTenant = Tenant::factory()->create([ 'status' => 'active', 'workspace_id' => (int) $backupTenant->workspace_id, 'name' => 'Recovery Tenant', ]); createUserWithTenant($recoveryTenant, $user, role: 'readonly', workspaceRole: 'readonly'); workspaceOverviewSeedQuietTenantTruth($recoveryTenant); $recoveryBackup = workspaceOverviewSeedHealthyBackup($recoveryTenant, [ 'completed_at' => now()->subMinutes(20), ]); workspaceOverviewSeedRestoreHistory($recoveryTenant, $recoveryBackup, 'follow_up'); mock(CapabilityResolver::class, function ($mock) use ($backupTenant, $recoveryTenant): void { $mock->shouldReceive('primeMemberships')->once(); $mock->shouldReceive('isMember') ->andReturnUsing(static function ($user, Tenant $tenant) use ($backupTenant, $recoveryTenant): bool { expect([(int) $backupTenant->getKey(), (int) $recoveryTenant->getKey()]) ->toContain((int) $tenant->getKey()); return true; }); $mock->shouldReceive('can') ->andReturnUsing(static function ($user, Tenant $tenant, string $capability) use ($backupTenant, $recoveryTenant): bool { expect([(int) $backupTenant->getKey(), (int) $recoveryTenant->getKey()]) ->toContain((int) $tenant->getKey()); return match ($capability) { Capabilities::TENANT_VIEW => false, default => false, }; }); }); $workspace = $backupTenant->workspace()->firstOrFail(); $overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user); $items = collect($overview['attention_items'])->keyBy('family'); expect($items->get('backup_health')['action_disabled'])->toBeFalse() ->and($items->get('backup_health')['destination']['kind'])->toBe('tenant_dashboard') ->and($items->get('backup_health')['destination']['disabled'])->toBeFalse() ->and($items->get('backup_health')['helper_text'])->toBeNull() ->and($items->get('backup_health')['url'])->toContain('/admin/t/') ->and($items->get('recovery_evidence')['action_disabled'])->toBeFalse() ->and($items->get('recovery_evidence')['destination']['kind'])->toBe('tenant_dashboard') ->and($items->get('recovery_evidence')['destination']['disabled'])->toBeFalse() ->and($items->get('recovery_evidence')['helper_text'])->toBeNull() ->and($items->get('recovery_evidence')['url'])->toContain('/admin/t/'); });