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, 'context' => [ 'environment_review_id' => (int) $review->getKey(), 'review_publication_resolution_case_id' => (int) $case->getKey(), ], ]); 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'); $packOptions = [ 'include_pii' => false, 'include_operations' => true, ]; $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(), 'fingerprint' => app(ReviewPackService::class)->computeFingerprintForReview($review, $packOptions), 'options' => $packOptions, '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, ]); spec387DeleteStoredReport($tenant, StoredReport::REPORT_TYPE_PERMISSION_POSTURE); spec387DeleteStoredReport($tenant, StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES); $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(); }); $permissionReport = spec387EnsureReadyStoredReport($review, StoredReport::REPORT_TYPE_PERMISSION_POSTURE); $adminRolesReport = spec387EnsureReadyStoredReport($review, StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES); $review->evidenceSnapshot?->items() ->where('dimension_key', 'permission_posture') ->update([ 'state' => EvidenceCompletenessState::Complete->value, 'source_record_id' => (int) $permissionReport->getKey(), 'source_fingerprint' => (string) $permissionReport->fingerprint, 'updated_at' => now(), ]); $review->evidenceSnapshot?->items() ->where('dimension_key', 'entra_admin_roles') ->update([ 'state' => EvidenceCompletenessState::Complete->value, 'source_record_id' => (int) $adminRolesReport->getKey(), 'source_fingerprint' => (string) $adminRolesReport->fingerprint, 'updated_at' => now(), ]); Storage::disk('exports')->put('review-packs/spec387-ready-return.zip', 'PK-test'); $review->forceFill([ 'status' => EnvironmentReviewStatus::Ready->value, 'published_at' => null, 'published_by_user_id' => null, ])->save(); $packOptions = [ 'include_pii' => false, 'include_operations' => true, ]; $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(), 'fingerprint' => app(ReviewPackService::class)->computeFingerprintForReview($review, $packOptions), 'options' => $packOptions, 'file_path' => 'review-packs/spec387-ready-return.zip', 'file_disk' => 'exports', ]); $review->forceFill([ '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, 'metadata' => $step->step_key === $stepKey->value ? array_replace(is_array($step->metadata) ? $step->metadata : [], [ 'readiness_fingerprint' => (string) $case->readiness_fingerprint, ]) : $step->metadata, ])->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 spec387DeleteStoredReport(ManagedEnvironment $tenant, string $reportType): void { StoredReport::query() ->where('workspace_id', (int) $tenant->workspace_id) ->where('managed_environment_id', (int) $tenant->getKey()) ->where('report_type', $reportType) ->delete(); } function spec387EnsureReadyStoredReport(EnvironmentReview $review, string $reportType): StoredReport { $report = StoredReport::query() ->where('workspace_id', (int) $review->workspace_id) ->where('managed_environment_id', (int) $review->managed_environment_id) ->where('report_type', $reportType) ->where('status', StoredReport::STATUS_READY) ->latest('id') ->first(); if ($report instanceof StoredReport) { return $report; } $factory = $reportType === StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES ? StoredReport::factory()->entraAdminRoles(['roles' => []]) : StoredReport::factory()->permissionPosture([ 'required_count' => 0, 'granted_count' => 0, 'permissions' => [], ]); return $factory->create([ 'workspace_id' => (int) $review->workspace_id, 'managed_environment_id' => (int) $review->managed_environment_id, 'report_type' => $reportType, 'status' => StoredReport::STATUS_READY, 'generated_at' => now()->addMinute(), 'created_at' => now()->addMinute(), 'updated_at' => now()->addMinute(), ]); } function spec387EnvironmentReviewHeaderActions(Testable $component): array { $instance = $component->instance(); if ($instance->getCachedHeaderActions() === []) { $instance->cacheInteractsWithHeaderActions(); } return $instance->getCachedHeaderActions(); }