470 lines
20 KiB
PHP
470 lines
20 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\StoredReport;
|
|
use App\Models\User;
|
|
use App\Services\EnvironmentReviews\EnvironmentReviewReadinessGate;
|
|
use App\Services\ReviewPackService;
|
|
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,
|
|
'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();
|
|
}
|