Automated PR created by Copilot: adds implementation and tests for specs/264 cross-tenant promotion execution. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #320
267 lines
13 KiB
PHP
267 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Filament\Pages\CrossTenantComparePage;
|
|
use App\Filament\Resources\TenantResource;
|
|
use App\Jobs\Operations\CrossTenantPromotionExecutionJob;
|
|
use App\Models\OperationRun;
|
|
use App\Models\Tenant;
|
|
use App\Services\Auth\CapabilityResolver;
|
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
|
use App\Support\Navigation\CanonicalNavigationContext;
|
|
use App\Support\RestoreSafety\RestoreResultAttention;
|
|
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
|
use Filament\Actions\Action;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Livewire\Livewire;
|
|
use Tests\Feature\Concerns\BuildsPortfolioCompareFixtures;
|
|
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
|
|
|
|
uses(RefreshDatabase::class, BuildsPortfolioTriageFixtures::class, BuildsPortfolioCompareFixtures::class);
|
|
|
|
function crossTenantCompareLaunchQuery(string $url): array
|
|
{
|
|
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
|
|
|
|
return $query;
|
|
}
|
|
|
|
it('launches cross-tenant compare from the tenant registry with target prefill and return context', function (): void {
|
|
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant');
|
|
$targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant');
|
|
|
|
$backupSet = $this->seedPortfolioBackupConcern($targetTenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
|
$this->seedPortfolioRecoveryConcern($targetTenant, RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP, $backupSet);
|
|
|
|
$triageState = $this->portfolioReturnFilters(
|
|
[TenantBackupHealthAssessment::POSTURE_STALE],
|
|
[TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED],
|
|
[],
|
|
TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
|
);
|
|
|
|
$expectedUrl = TenantResource::crossTenantCompareOpenUrl($targetTenant, $triageState);
|
|
|
|
$this->portfolioTriageRegistryList($user, $anchorTenant, $triageState)
|
|
->assertTableActionVisible('compareTenants', $targetTenant)
|
|
->assertTableActionHasUrl('compareTenants', $expectedUrl, $targetTenant);
|
|
|
|
$query = crossTenantCompareLaunchQuery($expectedUrl);
|
|
$backUrl = urldecode((string) data_get($query, 'nav.back_url'));
|
|
|
|
expect($query)->toMatchArray([
|
|
'target_tenant_id' => (string) $targetTenant->getKey(),
|
|
])
|
|
->and(data_get($query, 'nav.source_surface'))->toBe('tenant_registry')
|
|
->and(data_get($query, 'nav.tenant_id'))->toBe((string) $targetTenant->getKey())
|
|
->and(data_get($query, 'nav.back_label'))->toBe('Back to tenant registry')
|
|
->and($backUrl)->toContain('backup_posture[0]='.TenantBackupHealthAssessment::POSTURE_STALE)
|
|
->and($backUrl)->toContain('recovery_evidence[0]='.TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED)
|
|
->and($backUrl)->toContain('triage_sort='.TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST);
|
|
|
|
Livewire::withQueryParams($query)
|
|
->actingAs($user)
|
|
->test(CrossTenantComparePage::class)
|
|
->assertSet('sourceTenantId', null)
|
|
->assertSet('targetTenantId', (string) $targetTenant->getKey())
|
|
->assertActionVisible('return_to_origin')
|
|
->assertActionExists('return_to_origin', fn (Action $action): bool => $action->getLabel() === 'Back to tenant registry'
|
|
&& $action->getUrl() === TenantResource::getUrl(panel: 'admin', parameters: $triageState));
|
|
});
|
|
|
|
it('launches cross-tenant compare from an exact-two bulk selection with both tenants prefilled', function (): void {
|
|
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant');
|
|
$targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant');
|
|
|
|
$anchorBackupSet = $this->seedPortfolioBackupConcern($anchorTenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
|
$this->seedPortfolioRecoveryConcern($anchorTenant, RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP, $anchorBackupSet);
|
|
|
|
$backupSet = $this->seedPortfolioBackupConcern($targetTenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
|
$this->seedPortfolioRecoveryConcern($targetTenant, RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP, $backupSet);
|
|
|
|
$triageState = $this->portfolioReturnFilters(
|
|
[TenantBackupHealthAssessment::POSTURE_STALE],
|
|
[TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED],
|
|
[],
|
|
TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
|
);
|
|
|
|
$expectedUrl = TenantResource::crossTenantCompareOpenUrlForSelection(
|
|
targetTenant: $targetTenant,
|
|
triageState: $triageState,
|
|
sourceTenant: $anchorTenant,
|
|
);
|
|
|
|
$this->portfolioTriageRegistryList($user, $anchorTenant, $triageState)
|
|
->selectTableRecords([$anchorTenant, $targetTenant])
|
|
->assertTableBulkActionVisible('compareSelected')
|
|
->callTableBulkAction('compareSelected', [$anchorTenant, $targetTenant])
|
|
->assertRedirect($expectedUrl);
|
|
|
|
$query = crossTenantCompareLaunchQuery($expectedUrl);
|
|
$backUrl = urldecode((string) data_get($query, 'nav.back_url'));
|
|
|
|
expect($query)->toMatchArray([
|
|
'source_tenant_id' => (string) $anchorTenant->getKey(),
|
|
'target_tenant_id' => (string) $targetTenant->getKey(),
|
|
])
|
|
->and(data_get($query, 'nav.source_surface'))->toBe('tenant_registry')
|
|
->and(data_get($query, 'nav.back_label'))->toBe('Back to tenant registry')
|
|
->and(data_get($query, 'nav.tenant_id'))->toBeNull()
|
|
->and($backUrl)->toContain('backup_posture[0]='.TenantBackupHealthAssessment::POSTURE_STALE)
|
|
->and($backUrl)->toContain('recovery_evidence[0]='.TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED)
|
|
->and($backUrl)->toContain('triage_sort='.TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST);
|
|
|
|
Livewire::withQueryParams($query)
|
|
->actingAs($user)
|
|
->test(CrossTenantComparePage::class)
|
|
->assertSet('sourceTenantId', (string) $anchorTenant->getKey())
|
|
->assertSet('targetTenantId', (string) $targetTenant->getKey())
|
|
->assertActionVisible('return_to_origin');
|
|
});
|
|
|
|
it('keeps launch context after queueing promotion from an exact-two registry launch', function (): void {
|
|
Queue::fake();
|
|
|
|
[$user, $anchorTenant] = $this->makePortfolioTriageActor(
|
|
tenantName: 'Anchor Tenant',
|
|
workspaceRole: 'owner',
|
|
);
|
|
$targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant');
|
|
createMinimalUserWithTenant(
|
|
tenant: $targetTenant,
|
|
user: $user,
|
|
role: 'owner',
|
|
workspaceRole: 'owner',
|
|
);
|
|
|
|
$this->createPortfolioCompareSubject(
|
|
tenant: $anchorTenant,
|
|
displayName: 'Queued Launch Context Policy',
|
|
snapshot: ['settings' => [['key' => 'launch-context', 'value' => 1]]],
|
|
);
|
|
|
|
$triageState = $this->portfolioReturnFilters(
|
|
[TenantBackupHealthAssessment::POSTURE_STALE],
|
|
[TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED],
|
|
[],
|
|
TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
|
);
|
|
|
|
$expectedUrl = TenantResource::crossTenantCompareOpenUrlForSelection(
|
|
targetTenant: $targetTenant,
|
|
triageState: $triageState,
|
|
sourceTenant: $anchorTenant,
|
|
);
|
|
$expectedBackUrl = TenantResource::getUrl(panel: 'admin', parameters: $triageState);
|
|
$query = crossTenantCompareLaunchQuery($expectedUrl);
|
|
$query['policy_type'] = ['deviceConfiguration'];
|
|
|
|
$this->usePortfolioTriageWorkspace($user, $anchorTenant);
|
|
|
|
$component = Livewire::withQueryParams($query)
|
|
->actingAs($user)
|
|
->test(CrossTenantComparePage::class)
|
|
->assertSet('sourceTenantId', (string) $anchorTenant->getKey())
|
|
->assertSet('targetTenantId', (string) $targetTenant->getKey())
|
|
->assertSet('selectedPolicyTypes', ['deviceConfiguration'])
|
|
->assertActionVisible('return_to_origin')
|
|
->assertActionExists('return_to_origin', fn (Action $action): bool => $action->getLabel() === 'Back to tenant registry'
|
|
&& $action->getUrl() === $expectedBackUrl);
|
|
|
|
$page = $component->instance();
|
|
$page->generatePromotionPreflight();
|
|
$page->executePromotion();
|
|
|
|
$run = OperationRun::query()->latest('id')->first();
|
|
$navigationContext = CanonicalNavigationContext::fromPayload($page->navigationContextPayload);
|
|
|
|
expect($run)
|
|
->not->toBeNull()
|
|
->and($run?->type)->toBe('promotion.execute')
|
|
->and(data_get($run?->context, 'selection.sourceTenantId'))->toBe((int) $anchorTenant->getKey())
|
|
->and(data_get($run?->context, 'selection.targetTenantId'))->toBe((int) $targetTenant->getKey())
|
|
->and(data_get($run?->context, 'selection.policyTypes'))->toBe(['deviceConfiguration'])
|
|
->and($page->sourceTenantId)->toBe((string) $anchorTenant->getKey())
|
|
->and($page->targetTenantId)->toBe((string) $targetTenant->getKey())
|
|
->and($page->selectedPolicyTypes)->toBe(['deviceConfiguration'])
|
|
->and($page->navigationContextPayload)->toBe($query['nav'])
|
|
->and($navigationContext?->backLinkLabel)->toBe('Back to tenant registry')
|
|
->and($navigationContext?->backLinkUrl)->toBe($expectedBackUrl);
|
|
|
|
Queue::assertPushed(CrossTenantPromotionExecutionJob::class, function (CrossTenantPromotionExecutionJob $job) use ($run): bool {
|
|
return $job->getOperationRun()?->is($run);
|
|
});
|
|
});
|
|
|
|
it('rejects the bulk compare action until exactly two active tenants are selected', function (): void {
|
|
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant');
|
|
$targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant');
|
|
$thirdTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Third Tenant');
|
|
|
|
$this->portfolioTriageRegistryList($user, $anchorTenant)
|
|
->selectTableRecords([$anchorTenant])
|
|
->assertTableBulkActionVisible('compareSelected')
|
|
->callTableBulkAction('compareSelected', [$anchorTenant])
|
|
->assertNotified('Select exactly two tenants to compare.');
|
|
|
|
$this->portfolioTriageRegistryList($user, $anchorTenant)
|
|
->selectTableRecords([$anchorTenant, $targetTenant, $thirdTenant])
|
|
->assertTableBulkActionVisible('compareSelected')
|
|
->callTableBulkAction('compareSelected', [$anchorTenant, $targetTenant, $thirdTenant])
|
|
->assertNotified('Select exactly two tenants to compare.');
|
|
});
|
|
|
|
it('rejects the bulk compare action when a selected tenant is not active', function (): void {
|
|
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant');
|
|
$onboardingTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Onboarding Tenant');
|
|
|
|
$onboardingTenant->forceFill([
|
|
'status' => Tenant::STATUS_ONBOARDING,
|
|
])->save();
|
|
|
|
$this->portfolioTriageRegistryList($user, $anchorTenant)
|
|
->selectTableRecords([$anchorTenant, $onboardingTenant])
|
|
->assertTableBulkActionVisible('compareSelected')
|
|
->callTableBulkAction('compareSelected', [$anchorTenant, $onboardingTenant])
|
|
->assertNotified('Only active tenants can be compared.');
|
|
});
|
|
|
|
it('hides the compare launch action when workspace baseline view capability is missing', function (): void {
|
|
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant');
|
|
$targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant');
|
|
|
|
$resolver = \Mockery::mock(WorkspaceCapabilityResolver::class);
|
|
$resolver->shouldReceive('isMember')->andReturnTrue();
|
|
$resolver->shouldReceive('can')->andReturnFalse();
|
|
app()->instance(WorkspaceCapabilityResolver::class, $resolver);
|
|
|
|
$this->portfolioTriageRegistryList($user, $anchorTenant)
|
|
->assertTableActionHidden('compareTenants', $targetTenant);
|
|
});
|
|
|
|
it('hides the compare launch action when the actor lacks tenant view on the launched tenant', function (): void {
|
|
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant');
|
|
$targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant');
|
|
|
|
$resolver = \Mockery::mock(CapabilityResolver::class);
|
|
$resolver->shouldReceive('primeMemberships')->andReturnNull();
|
|
$resolver->shouldReceive('isMember')->andReturnTrue();
|
|
$resolver->shouldReceive('can')->andReturnUsing(function (mixed $actor, mixed $tenant, string $capability) use ($targetTenant): bool {
|
|
if ($tenant instanceof Tenant
|
|
&& (int) $tenant->getKey() === (int) $targetTenant->getKey()
|
|
&& $capability === Capabilities::TENANT_VIEW) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
app()->instance(CapabilityResolver::class, $resolver);
|
|
|
|
$this->portfolioTriageRegistryList($user, $anchorTenant)
|
|
->assertTableActionHidden('compareTenants', $targetTenant);
|
|
}); |