TenantAtlas/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
ahmido 641bb4afde feat: implement tenant lifecycle operability semantics (#172)
## Summary
- implement Spec 143 tenant lifecycle, operability, and tenant-context semantics across chooser, tenant management, onboarding, and canonical operation viewers
- add centralized tenant lifecycle and operability support types, audit action coverage, and lifecycle-aware badge and action handling
- add feature and unit coverage for tenant chooser eligibility, global search scoping, canonical operation access, onboarding authorization, and lifecycle presentation

## Testing
- vendor/bin/sail artisan test --compact
- vendor/bin/sail bin pint --dirty --format agent

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #172
2026-03-15 09:08:36 +00:00

232 lines
7.9 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Pages\Monitoring\Operations;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('serves /admin/operations without tenant context (workspace-wide)', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
]);
$user->tenants()->syncWithoutDetaching([
$tenantB->getKey() => ['role' => 'owner'],
]);
$runA = OperationRun::factory()->create([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'type' => 'policy.sync',
'initiator_name' => 'TenantA',
]);
$runB = OperationRun::factory()->create([
'tenant_id' => (int) $tenantB->getKey(),
'workspace_id' => (int) $tenantB->workspace_id,
'type' => 'inventory_sync',
'initiator_name' => 'TenantB',
]);
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
->get('/admin/operations')
->assertOk()
->assertSee('Policy sync')
->assertSee('Inventory sync')
->assertSee('TenantA')
->assertSee('TenantB');
});
it('serves /admin/operations/{run} with and without tenant context', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'policy.sync',
]);
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Operation run')
->assertDontSee('/admin/t/'.((int) $tenant->getKey()).'/operations/r/'.((int) $run->getKey()));
Filament::setTenant($tenant, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Operation run');
});
it('keeps operation detail accessible when the active tenant context does not match the run tenant', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
$runB = OperationRun::factory()->create([
'tenant_id' => (int) $tenantB->getKey(),
'workspace_id' => (int) $tenantB->workspace_id,
'type' => 'inventory_sync',
]);
Filament::setTenant($tenantA, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $runB->getKey()]))
->assertOk()
->assertSee('Operation run');
});
it('defaults the tenant filter from tenant context and can be cleared', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
]);
$user->tenants()->syncWithoutDetaching([
$tenantB->getKey() => ['role' => 'owner'],
]);
$runA = OperationRun::factory()->create([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'type' => 'policy.sync',
'initiator_name' => 'TenantA',
]);
$runB = OperationRun::factory()->create([
'tenant_id' => (int) $tenantB->getKey(),
'workspace_id' => (int) $tenantB->workspace_id,
'type' => 'inventory_sync',
'initiator_name' => 'TenantB',
]);
Filament::setTenant($tenantA, true);
$this->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
]);
session([
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
]);
$component = Livewire::actingAs($user)
->test(Operations::class)
->assertCanSeeTableRecords([$runA])
->assertCanNotSeeTableRecords([$runB])
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey());
$component
->callAction('operate_hub_show_all_tenants')
->assertSet('tableFilters.tenant_id.value', null)
->assertRedirect('/admin/operations');
Filament::setTenant(null, true);
Livewire::actingAs($user)
->test(Operations::class)
->assertSee('TenantA')
->assertSee('TenantB');
});
it('shows an explicit back-link when canonical context is present on the operations index', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
Filament::setTenant($tenant, true);
$context = new CanonicalNavigationContext(
sourceSurface: 'backup_set.detail_section',
canonicalRouteName: 'admin.operations.index',
tenantId: (int) $tenant->getKey(),
backLinkLabel: 'Back to backup set',
backLinkUrl: '/admin/tenant/backup-sets/1',
);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(OperationRunLinks::index($tenant, $context))
->assertOk()
->assertSee('Back to backup set')
->assertSee('/admin/tenant/backup-sets/1', false);
});
it('keeps the canonical back-link action after Livewire hydration on the operations index', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
Filament::setTenant($tenant, true);
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
Livewire::withQueryParams([
'nav' => [
'source_surface' => 'finding.list_row',
'canonical_route_name' => 'admin.operations.index',
'tenant_id' => (int) $tenant->getKey(),
'back_label' => 'Back to findings',
'back_url' => '/admin/findings?tenant='.$tenant->external_id,
],
])
->actingAs($user)
->test(Operations::class)
->assertActionVisible('operate_hub_back_to_origin_operations');
});
it('does not register legacy operation resource routes', function (): void {
expect(Route::has('filament.admin.resources.operations.index'))->toBeFalse();
expect(Route::has('filament.admin.resources.operations.view'))->toBeFalse();
});
it('has reserved Monitoring placeholder pages for Alerts and Audit Log', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->followingRedirects()
->get('/admin/alerts')
->assertOk();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/audit-log')
->assertOk();
});