TenantAtlas/apps/platform/tests/Feature/EnvironmentReview/Spec387ReviewPublicationResolutionDecisionUxTest.php
ahmido aca0b10658 feat: add review publication resolution ux spec and tests (#458)
Automated PR created by Codex via Gitea API.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #458
2026-06-19 08:49:26 +00:00

390 lines
17 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\EnvironmentReviewResource\Pages\ResolveReviewPublication;
use App\Filament\Resources\EnvironmentReviewResource\Pages\ViewEnvironmentReview;
use App\Models\EnvironmentReview;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Models\ReviewPublicationResolutionCase;
use App\Models\User;
use App\Services\EnvironmentReviews\EnvironmentReviewReadinessGate;
use App\Support\EnvironmentReviewStatus;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionActionService;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionCaseStatus;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionService;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionStepKey;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionStepStatus;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
beforeEach(function (): void {
Storage::fake('exports');
});
it('Spec387 renders the decision page with operator labels and proof secondary', function (): void {
[$owner, $tenant, $review] = spec387BlockedReviewFixture();
setAdminEnvironmentContext($tenant);
Livewire::actingAs($owner)
->test(ResolveReviewPublication::class, ['record' => $review->getKey()])
->assertSee('Review can\'t be published yet')
->assertSeeInOrder([
'Publication preparation',
'Why publication is blocked',
'Next safe action',
'What happens after this',
'Technical proof and operation history',
])
->assertSee('Update required reports')
->assertSee('Collect evidence')
->assertSee('Refresh review')
->assertSee('Prepare export')
->assertSee('Return to review')
->assertSee('Required reports')
->assertSee('Permission posture')
->assertSee('Entra admin roles')
->assertSee('will not publish the review')
->assertSeeHtml('collapsed')
->assertDontSeeText('Generate review pack')
->assertDontSeeText('Return to publication')
->assertDontSeeText('Resolution Case')
->assertDontSeeText('Case Status')
->assertDontSeeText('Current step')
->assertDontSeeText('Resolution steps')
->assertDontSeeText('Report-backed evidence')
->assertDontSeeText('OperationRun')
->assertDontSeeText('Artifact proof')
->assertActionDoesNotExist('publish_review')
->assertActionExists('back_to_review', fn (Action $action): bool => $action->getLabel() === 'Return to review');
});
it('Spec387 uses action-specific confirmation copy for each current step', function (
ReviewPublicationResolutionStepKey $stepKey,
string $expectedLabel,
string $expectedDescription,
): void {
[$owner, $tenant, $review, $case] = spec387BlockedReviewFixture();
[$readonly] = createUserWithTenant(
tenant: $tenant,
user: User::factory()->create(),
role: 'readonly',
workspaceRole: 'readonly',
);
spec387ForceCurrentStep($case, $stepKey);
setAdminEnvironmentContext($tenant);
Livewire::actingAs($readonly)
->test(ResolveReviewPublication::class, ['record' => $review->getKey()])
->assertActionExists('execute_current_step', function (Action $action) use ($expectedLabel, $expectedDescription): bool {
return $action->getLabel() === $expectedLabel
&& $action->isConfirmationRequired()
&& (string) $action->getModalHeading() === $expectedLabel.'?'
&& $action->getModalSubmitActionLabel() === $expectedLabel
&& (string) $action->getModalDescription() === $expectedDescription;
});
})->with([
'required reports' => [
ReviewPublicationResolutionStepKey::CompleteRequiredReports,
'Update required reports',
'TenantPilot will update the missing required reports. This will not publish the review.',
],
'collect evidence' => [
ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot,
'Collect evidence',
'TenantPilot will collect a current evidence snapshot for this review. This will not publish the review.',
],
'refresh review' => [
ReviewPublicationResolutionStepKey::RefreshReviewComposition,
'Refresh review',
'TenantPilot will refresh the review from current evidence. This will not publish the review.',
],
'prepare export' => [
ReviewPublicationResolutionStepKey::GenerateReviewPack,
'Prepare export',
'TenantPilot will prepare the customer-ready export package for this review. This will not publish the review.',
],
'return to review' => [
ReviewPublicationResolutionStepKey::ReturnToPublication,
'Return to review',
'TenantPilot will return you to the review. Publishing remains a separate action.',
],
]);
it('Spec387 makes readonly inspection explicit and keeps direct execution denied', function (): void {
[$owner, $tenant, $review, $case] = spec387BlockedReviewFixture();
[$readonly] = createUserWithTenant(
tenant: $tenant,
user: User::factory()->create(),
role: 'readonly',
workspaceRole: 'readonly',
);
Queue::fake();
setAdminEnvironmentContext($tenant);
expect($readonly->can('view', $case))->toBeTrue()
->and($readonly->can('executeStep', $case))->toBeFalse();
Livewire::actingAs($readonly)
->test(ResolveReviewPublication::class, ['record' => $review->getKey()])
->assertSee('You can inspect this preparation flow, but you do not have permission to run the next action.')
->assertActionVisible('execute_current_step')
->assertActionDisabled('execute_current_step');
expect(fn () => app(ReviewPublicationResolutionActionService::class)->executeCurrentStep($case->fresh(), $readonly))
->toThrow(AuthorizationException::class);
Queue::assertNothingPushed();
});
it('Spec387 shows waiting and failed operation states without exposing a duplicate running action', function (): void {
[$owner, $tenant, $review, $case] = spec387BlockedReviewFixture();
$run = OperationRun::factory()->forTenant($tenant)->create([
'type' => OperationRunType::EntraAdminRolesScan->value,
'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value,
]);
spec387ForceCurrentStep($case, ReviewPublicationResolutionStepKey::CompleteRequiredReports, ReviewPublicationResolutionStepStatus::Running, $run);
setAdminEnvironmentContext($tenant);
Livewire::actingAs($owner)
->test(ResolveReviewPublication::class, ['record' => $review->getKey()])
->assertSee('Operation in progress')
->assertSee('TenantPilot is waiting for the linked operation to finish. No new start action is available while it runs.')
->assertActionHidden('execute_current_step');
$run->forceFill([
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'completed_at' => now(),
])->save();
spec387ForceCurrentStep($case, ReviewPublicationResolutionStepKey::CompleteRequiredReports, ReviewPublicationResolutionStepStatus::Failed, $run);
Livewire::actingAs($owner)
->test(ResolveReviewPublication::class, ['record' => $review->getKey()])
->assertSee('Action needed')
->assertSee('The last operation did not complete. Review the linked operation, then retry the current preparation action when you are ready.')
->assertActionVisible('execute_current_step')
->assertActionEnabled('execute_current_step');
});
it('Spec387 shows ready-to-continue copy without moving publish onto the resolution page', function (): void {
[$owner, $tenant, $review, $case] = spec387BlockedReviewFixture();
spec387MakeReviewReadyForReturn($review, $owner);
$case = app(ReviewPublicationResolutionService::class)->openOrResume($review->fresh(['sections', 'evidenceSnapshot.items', 'currentExportReviewPack']), $owner);
setAdminEnvironmentContext($tenant);
expect(app(EnvironmentReviewReadinessGate::class)->blockersForReview($review->fresh('sections')))->toBe([]);
expect($case?->current_step_key)->toBe(ReviewPublicationResolutionStepKey::ReturnToPublication->value)
->and($case?->status)->toBe(ReviewPublicationResolutionCaseStatus::ReadyToContinue->value);
Livewire::actingAs($owner)
->test(ResolveReviewPublication::class, ['record' => $review->getKey()])
->assertSee('Review is ready to continue')
->assertSee('Ready to continue')
->assertSee('Return to review')
->assertSee('Publishing remains a separate action on the review page.')
->assertActionDoesNotExist('publish_review');
});
it('Spec387 keeps the blocked review detail CTA primary while publish stays non-primary', function (): void {
[$owner, $tenant, $review] = spec387BlockedReviewFixture();
setAdminEnvironmentContext($tenant);
$component = Livewire::actingAs($owner)
->test(ViewEnvironmentReview::class, ['record' => $review->getKey()])
->assertActionVisible('resolve_publication_blockers')
->assertActionExists('resolve_publication_blockers', fn (Action $action): bool => $action->getLabel() === 'Resolve publication blockers');
$topLevelActionNames = collect(spec387EnvironmentReviewHeaderActions($component))
->reject(static fn ($action): bool => $action instanceof ActionGroup)
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
->filter()
->values()
->all();
expect($topLevelActionNames)->toBe(['resolve_publication_blockers']);
});
it('Spec387 keeps customer review workspace free of resolution internals', function (): void {
[$owner, $tenant, $review] = spec387BlockedReviewFixture();
$review = markEnvironmentReviewCustomerSafeReady($review);
$review->forceFill([
'status' => EnvironmentReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $owner->getKey(),
])->save();
Storage::disk('exports')->put('review-packs/spec387-customer-safe.zip', 'PK-test');
$pack = ReviewPack::factory()->ready()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'environment_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $review->evidence_snapshot_id,
'initiated_by_user_id' => (int) $owner->getKey(),
'file_path' => 'review-packs/spec387-customer-safe.zip',
'file_disk' => 'exports',
]);
$review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setAdminPanelContext();
Livewire::actingAs($owner)
->test(CustomerReviewWorkspace::class)
->assertSee('Customer Review Workspace')
->assertDontSee('Resolution Case')
->assertDontSee('Current step')
->assertDontSee('OperationRun')
->assertDontSee('Artifact proof')
->assertDontSee('complete_required_reports')
->assertDontSee('generate_review_pack')
->assertDontSee('return_to_publication');
});
/**
* @return array{0: User, 1: ManagedEnvironment, 2: EnvironmentReview, 3: ReviewPublicationResolutionCase}
*/
function spec387BlockedReviewFixture(): array
{
$tenant = ManagedEnvironment::factory()->create(['name' => 'Spec387 Resolution']);
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', workspaceRole: 'manager');
$snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
$snapshot->items()->whereIn('dimension_key', ['permission_posture', 'entra_admin_roles'])->update([
'state' => EvidenceCompletenessState::Missing->value,
'source_record_id' => null,
'source_fingerprint' => null,
]);
$review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot);
$case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner);
expect($case)->toBeInstanceOf(ReviewPublicationResolutionCase::class);
return [$owner, $tenant, $review, $case];
}
function spec387MakeReviewReadyForReturn(EnvironmentReview $review, User $owner): EnvironmentReview
{
$review = markEnvironmentReviewCustomerSafeReady($review);
$review->sections->each(function ($section): void {
$summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : [];
$baselineReadiness = is_array($summaryPayload['baseline_readiness'] ?? null)
? $summaryPayload['baseline_readiness']
: [];
$summaryPayload['publication_blockers'] = [];
if ($baselineReadiness !== []) {
$summaryPayload['baseline_readiness'] = array_replace($baselineReadiness, [
'publication_blockers' => [],
]);
}
$section->forceFill([
'summary_payload' => $summaryPayload,
])->save();
});
$review->evidenceSnapshot?->items()
->whereIn('dimension_key', ['permission_posture', 'entra_admin_roles'])
->update([
'state' => EvidenceCompletenessState::Complete->value,
'source_record_id' => '1',
'source_fingerprint' => 'spec387-ready-report',
'updated_at' => now(),
]);
Storage::disk('exports')->put('review-packs/spec387-ready-return.zip', 'PK-test');
$pack = ReviewPack::factory()->ready()->create([
'managed_environment_id' => (int) $review->managed_environment_id,
'workspace_id' => (int) $review->workspace_id,
'environment_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $review->evidence_snapshot_id,
'initiated_by_user_id' => (int) $owner->getKey(),
'file_path' => 'review-packs/spec387-ready-return.zip',
'file_disk' => 'exports',
]);
$review->forceFill([
'status' => EnvironmentReviewStatus::Ready->value,
'published_at' => null,
'published_by_user_id' => null,
'current_export_review_pack_id' => (int) $pack->getKey(),
])->save();
return $review->fresh(['evidenceSnapshot.items', 'currentExportReviewPack', 'sections']);
}
function spec387ForceCurrentStep(
ReviewPublicationResolutionCase $case,
ReviewPublicationResolutionStepKey $stepKey,
ReviewPublicationResolutionStepStatus $status = ReviewPublicationResolutionStepStatus::Actionable,
?OperationRun $operationRun = null,
): ReviewPublicationResolutionCase {
$case->loadMissing('steps');
foreach ($case->steps as $step) {
$step->forceFill([
'status' => $step->step_key === $stepKey->value
? $status->value
: ReviewPublicationResolutionStepStatus::Completed->value,
'operation_run_id' => $step->step_key === $stepKey->value ? $operationRun?->getKey() : null,
'proof_type' => $step->step_key === $stepKey->value && $operationRun instanceof OperationRun ? 'operation_run' : $step->proof_type,
'proof_id' => $step->step_key === $stepKey->value && $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : $step->proof_id,
'proof_status' => $step->step_key === $stepKey->value && $operationRun instanceof OperationRun ? (string) $operationRun->outcome : $step->proof_status,
])->save();
}
$caseStatus = match ($status) {
ReviewPublicationResolutionStepStatus::Running => ReviewPublicationResolutionCaseStatus::WaitingForRun,
ReviewPublicationResolutionStepStatus::Failed => ReviewPublicationResolutionCaseStatus::Blocked,
default => $stepKey === ReviewPublicationResolutionStepKey::ReturnToPublication
? ReviewPublicationResolutionCaseStatus::ReadyToContinue
: ReviewPublicationResolutionCaseStatus::InProgress,
};
$case->forceFill([
'current_step_key' => $stepKey->value,
'status' => $caseStatus->value,
])->save();
return $case->fresh('steps.operationRun');
}
function spec387EnvironmentReviewHeaderActions(Testable $component): array
{
$instance = $component->instance();
if ($instance->getCachedHeaderActions() === []) {
$instance->cacheInteractsWithHeaderActions();
}
return $instance->getCachedHeaderActions();
}