## Summary\n- Implements the ReviewPublicationResolutionWorkflow for Spec 386.\n- Adds resolution case/step persistence, policies, services, audit action IDs, and Filament integration.\n- Updates specs, UI/UX documentation, screenshots, and Pest coverage.\n\n## Tests\n- Not run during this handoff; branch was already clean and pushed.\n\n## Target\n- Base: platform-dev\n- Head/topic: 386-review-publication-resolution-workflow-v1 Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #457
356 lines
20 KiB
PHP
356 lines
20 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Filament\Resources\EnvironmentReviewResource;
|
|
use App\Filament\Resources\EnvironmentReviewResource\Pages\ResolveReviewPublication;
|
|
use App\Filament\Resources\EnvironmentReviewResource\Pages\ViewEnvironmentReview;
|
|
use App\Jobs\ScanEntraAdminRolesJob;
|
|
use App\Models\AuditLog;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ReviewPublicationResolutionCase;
|
|
use App\Models\User;
|
|
use App\Services\ReviewPackService;
|
|
use App\Support\Audit\AuditActionId;
|
|
use App\Support\Auth\Capabilities;
|
|
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 Filament\Actions\Action;
|
|
use Illuminate\Auth\Access\AuthorizationException;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
it('creates a review publication resolution case from current readiness truth', function (): void {
|
|
$tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Case']);
|
|
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
$snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
|
|
$review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot);
|
|
|
|
$case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner);
|
|
|
|
expect($case)->toBeInstanceOf(ReviewPublicationResolutionCase::class)
|
|
->and($case->status)->toBe(ReviewPublicationResolutionCaseStatus::InProgress->value)
|
|
->and($case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::ValidateReviewReadiness->value)?->status)
|
|
->toBe(ReviewPublicationResolutionStepStatus::Completed->value)
|
|
->and($case->current_step_key)->not->toBeNull();
|
|
|
|
expect(AuditLog::query()
|
|
->where('action', AuditActionId::ReviewPublicationResolutionCreated->value)
|
|
->where('resource_id', (string) $case->getKey())
|
|
->exists())->toBeTrue();
|
|
});
|
|
|
|
it('does not create a resolution case when a mutable review is already publishable', function (): void {
|
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
$review = composeEnvironmentReviewForTest($tenant, $owner);
|
|
|
|
$case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner);
|
|
|
|
expect($case)->toBeNull()
|
|
->and(ReviewPublicationResolutionCase::query()->count())->toBe(0);
|
|
});
|
|
|
|
it('does not let readonly actors create resolution cases', function (): void {
|
|
$tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Readonly No Create']);
|
|
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
[$readonly] = createUserWithTenant(tenant: $tenant, user: User::factory()->create(), role: 'readonly', workspaceRole: 'readonly');
|
|
$snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
|
|
$review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot);
|
|
|
|
expect(fn () => app(ReviewPublicationResolutionService::class)->openOrResume($review, $readonly))
|
|
->toThrow(AuthorizationException::class)
|
|
->and(ReviewPublicationResolutionCase::query()->forReview($review)->count())->toBe(0);
|
|
|
|
setAdminEnvironmentContext($tenant);
|
|
|
|
Livewire::actingAs($readonly)
|
|
->test(ResolveReviewPublication::class, ['record' => $review->getKey()])
|
|
->assertForbidden();
|
|
|
|
expect(ReviewPublicationResolutionCase::query()->forReview($review)->count())->toBe(0);
|
|
});
|
|
|
|
it('supersedes an active resolution case when the readiness fingerprint changes', function (): void {
|
|
$tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Supersede']);
|
|
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
$snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
|
|
$review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot);
|
|
|
|
$firstCase = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner);
|
|
|
|
$section = $review->sections()->where('required', true)->firstOrFail();
|
|
$section->forceFill([
|
|
'summary_payload' => array_replace_recursive(is_array($section->summary_payload) ? $section->summary_payload : [], [
|
|
'publication_blockers' => ['Spec386 changed blocker.'],
|
|
]),
|
|
])->save();
|
|
|
|
$secondCase = app(ReviewPublicationResolutionService::class)->openOrResume($review->fresh(['sections', 'evidenceSnapshot.items']), $owner);
|
|
|
|
expect($secondCase)->not->toBeNull()
|
|
->and($secondCase->getKey())->not->toBe($firstCase?->getKey())
|
|
->and($firstCase?->fresh()->status)->toBe(ReviewPublicationResolutionCaseStatus::Superseded->value)
|
|
->and(AuditLog::query()
|
|
->where('action', AuditActionId::ReviewPublicationResolutionSuperseded->value)
|
|
->where('resource_id', (string) $firstCase?->getKey())
|
|
->exists())->toBeTrue();
|
|
});
|
|
|
|
it('keeps readonly actors able to inspect but unable to execute resolution steps', function (): void {
|
|
$tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Readonly']);
|
|
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
[$readonly] = createUserWithTenant(tenant: $tenant, user: User::factory()->create(), role: 'readonly', workspaceRole: 'readonly');
|
|
$snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
|
|
$review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot);
|
|
$case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner);
|
|
|
|
expect($readonly->can('view', $case))->toBeTrue()
|
|
->and($readonly->can('executeStep', $case))->toBeFalse();
|
|
|
|
setAdminEnvironmentContext($tenant);
|
|
|
|
Livewire::actingAs($readonly)
|
|
->test(ResolveReviewPublication::class, ['record' => $review->getKey()])
|
|
->assertSee('Review can\'t be published yet')
|
|
->assertSee('Publication preparation')
|
|
->assertDontSee('Report-backed evidence')
|
|
->assertActionVisible('execute_current_step')
|
|
->assertActionDisabled('execute_current_step');
|
|
});
|
|
|
|
it('requires confirmation before executing the current resolution step', function (): void {
|
|
$tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Confirm Execute']);
|
|
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
$snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
|
|
$review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot);
|
|
|
|
app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner);
|
|
setAdminEnvironmentContext($tenant);
|
|
|
|
Livewire::actingAs($owner)
|
|
->test(ResolveReviewPublication::class, ['record' => $review->getKey()])
|
|
->assertActionExists('execute_current_step', fn (Action $action): bool => $action->isConfirmationRequired());
|
|
});
|
|
|
|
it('authorizes provider report resolution from the provider capability instead of review manage', function (): void {
|
|
$tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Provider Capability']);
|
|
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
[$operator] = createUserWithTenant(tenant: $tenant, user: User::factory()->create(), role: 'operator', workspaceRole: 'operator');
|
|
$snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
|
|
$review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot);
|
|
$case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner);
|
|
|
|
expect($case?->current_step_key)->toBe(ReviewPublicationResolutionStepKey::CompleteRequiredReports->value)
|
|
->and($operator->can(Capabilities::PROVIDER_RUN, $tenant))->toBeTrue()
|
|
->and($operator->can(Capabilities::ENVIRONMENT_REVIEW_MANAGE, $tenant))->toBeFalse()
|
|
->and($operator->can('executeStep', $case))->toBeTrue();
|
|
|
|
setAdminEnvironmentContext($tenant);
|
|
|
|
Livewire::actingAs($operator)
|
|
->test(ResolveReviewPublication::class, ['record' => $review->getKey()])
|
|
->assertActionVisible('execute_current_step')
|
|
->assertActionEnabled('execute_current_step');
|
|
});
|
|
|
|
it('plans only relevant required steps for the current blocker set', function (): void {
|
|
$tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Relevant Steps']);
|
|
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
$snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
|
|
$snapshot = restateEnvironmentReviewEvidenceSnapshot($snapshot, EvidenceCompletenessState::Complete);
|
|
$snapshot->items()->whereIn('dimension_key', ['permission_posture', 'entra_admin_roles'])->update([
|
|
'state' => EvidenceCompletenessState::Complete->value,
|
|
]);
|
|
$snapshot->items()->where('dimension_key', 'permission_posture')->update([
|
|
'state' => EvidenceCompletenessState::Missing->value,
|
|
'source_record_id' => null,
|
|
'source_fingerprint' => null,
|
|
]);
|
|
$review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot->fresh('items'));
|
|
|
|
$case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner);
|
|
$stepKeys = $case?->steps->pluck('step_key')->all();
|
|
|
|
expect($stepKeys)->toContain(ReviewPublicationResolutionStepKey::CompleteRequiredReports->value)
|
|
->and($stepKeys)->not->toContain(ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value);
|
|
});
|
|
|
|
it('lets current readiness truth supersede stale failed operation proof on an existing step', function (): void {
|
|
$tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Stale Proof']);
|
|
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
$snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
|
|
$review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot);
|
|
$case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner);
|
|
$step = $case?->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value);
|
|
$failedRun = OperationRun::factory()->forTenant($tenant)->create([
|
|
'type' => 'provider.connection.check',
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Failed->value,
|
|
'completed_at' => now()->subMinute(),
|
|
]);
|
|
|
|
$step?->forceFill([
|
|
'status' => ReviewPublicationResolutionStepStatus::Failed->value,
|
|
'operation_run_id' => (int) $failedRun->getKey(),
|
|
'proof_type' => 'operation_run',
|
|
'proof_id' => (int) $failedRun->getKey(),
|
|
'proof_status' => OperationRunOutcome::Failed->value,
|
|
])->save();
|
|
|
|
$snapshot = restateEnvironmentReviewEvidenceSnapshot($snapshot, EvidenceCompletenessState::Complete);
|
|
$snapshot->items()->whereIn('dimension_key', ['permission_posture', 'entra_admin_roles'])->update([
|
|
'state' => EvidenceCompletenessState::Complete->value,
|
|
]);
|
|
|
|
app(ReviewPublicationResolutionService::class)->refreshCase($case->fresh(['steps.operationRun', 'environmentReview']), $owner);
|
|
|
|
expect($step?->fresh()->status)->toBe(ReviewPublicationResolutionStepStatus::Completed->value);
|
|
});
|
|
|
|
it('queues the existing report operation when executing the required reports step', function (): void {
|
|
Queue::fake();
|
|
|
|
$tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Report Step']);
|
|
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
$snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
|
|
$snapshot->items()->whereIn('dimension_key', ['permission_posture', 'entra_admin_roles'])->update([
|
|
'state' => EvidenceCompletenessState::Complete->value,
|
|
]);
|
|
$snapshot->items()->where('dimension_key', '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);
|
|
|
|
$result = app(ReviewPublicationResolutionActionService::class)->executeCurrentStep($case, $owner);
|
|
$updatedCase = $result['case']->fresh('steps.operationRun');
|
|
$runningStep = $updatedCase->steps->firstWhere('status', ReviewPublicationResolutionStepStatus::Running->value);
|
|
|
|
expect($result['operation_type'])->toBe(OperationRunType::EntraAdminRolesScan->value)
|
|
->and($updatedCase->status)->toBe(ReviewPublicationResolutionCaseStatus::WaitingForRun->value)
|
|
->and($runningStep)->not->toBeNull()
|
|
->and($runningStep?->step_key)->toBe(ReviewPublicationResolutionStepKey::CompleteRequiredReports->value)
|
|
->and($runningStep?->operationRun?->type)->toBe(OperationRunType::EntraAdminRolesScan->value)
|
|
->and(AuditLog::query()
|
|
->where('action', AuditActionId::ReviewPublicationResolutionOperationLinked->value)
|
|
->where('resource_id', (string) $updatedCase->getKey())
|
|
->exists())->toBeTrue();
|
|
|
|
Queue::assertPushed(ScanEntraAdminRolesJob::class);
|
|
});
|
|
|
|
it('does not persist raw source-service exception text in failed step audit payloads', function (): void {
|
|
$tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Redacted Failure']);
|
|
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
$snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
|
|
$review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot);
|
|
$case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner);
|
|
$review = markEnvironmentReviewCustomerSafeReady($review);
|
|
$review->forceFill([
|
|
'status' => EnvironmentReviewStatus::Ready->value,
|
|
'current_export_review_pack_id' => null,
|
|
])->save();
|
|
$review->sections()->get()->each(function ($section): void {
|
|
$summary = is_array($section->summary_payload) ? $section->summary_payload : [];
|
|
$baselineReadiness = is_array($summary['baseline_readiness'] ?? null) ? $summary['baseline_readiness'] : [];
|
|
$baselineReadiness['publication_blockers'] = [];
|
|
|
|
$section->forceFill([
|
|
'completeness_state' => \App\Support\EnvironmentReviewCompletenessState::Complete->value,
|
|
'summary_payload' => array_replace($summary, [
|
|
'publication_blockers' => [],
|
|
'baseline_readiness' => $baselineReadiness,
|
|
]),
|
|
])->save();
|
|
});
|
|
$case = app(ReviewPublicationResolutionService::class)->refreshCase($case->fresh(['steps.operationRun', 'environmentReview']), $owner);
|
|
|
|
expect($case->current_step_key)->toBe(ReviewPublicationResolutionStepKey::GenerateReviewPack->value);
|
|
|
|
app()->bind(ReviewPackService::class, fn (): ReviewPackService => new class extends ReviewPackService
|
|
{
|
|
public function __construct() {}
|
|
|
|
public function generateFromReview(\App\Models\EnvironmentReview $review, User $user, array $options = []): \App\Models\ReviewPack
|
|
{
|
|
throw new RuntimeException('secret-token=abc123 rawGraphPayload {"access_token":"xyz"}');
|
|
}
|
|
});
|
|
|
|
expect(fn () => app(ReviewPublicationResolutionActionService::class)->executeCurrentStep($case, $owner))
|
|
->toThrow(RuntimeException::class);
|
|
|
|
$failedStep = $case?->fresh('steps')->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::GenerateReviewPack->value);
|
|
$failureAudit = AuditLog::query()
|
|
->where('action', AuditActionId::ReviewPublicationResolutionStepFailed->value)
|
|
->where('resource_id', (string) $case?->getKey())
|
|
->latest('id')
|
|
->firstOrFail();
|
|
$auditPayload = json_encode($failureAudit->metadata, JSON_THROW_ON_ERROR);
|
|
$stepPayload = json_encode($failedStep?->summary ?? [], JSON_THROW_ON_ERROR);
|
|
|
|
expect($failedStep?->summary)->toHaveKey('failure_code')
|
|
->and($failedStep?->summary)->not->toHaveKey('failure')
|
|
->and($auditPayload)->toContain('review_publication_resolution.step_failed_before_queue')
|
|
->and($auditPayload)->not->toContain('secret-token', 'rawGraphPayload', 'access_token')
|
|
->and($stepPayload)->not->toContain('secret-token', 'rawGraphPayload', 'access_token');
|
|
});
|
|
|
|
it('queues the existing evidence operation when executing the evidence collection step', function (): void {
|
|
$tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Evidence Step']);
|
|
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
$snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
|
|
$snapshot->items()->whereIn('dimension_key', ['permission_posture', 'entra_admin_roles'])->update([
|
|
'state' => EvidenceCompletenessState::Complete->value,
|
|
]);
|
|
$review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot->fresh('items'));
|
|
$case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner);
|
|
|
|
$result = app(ReviewPublicationResolutionActionService::class)->executeCurrentStep($case, $owner);
|
|
$updatedCase = $result['case']->fresh('steps.operationRun');
|
|
$runningStep = $updatedCase->steps->firstWhere('status', ReviewPublicationResolutionStepStatus::Running->value);
|
|
|
|
expect($result['operation_type'])->toBe(OperationRunType::EvidenceSnapshotGenerate->value)
|
|
->and($updatedCase->status)->toBe(ReviewPublicationResolutionCaseStatus::WaitingForRun->value)
|
|
->and($runningStep)->not->toBeNull()
|
|
->and($runningStep?->step_key)->toBe(ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value)
|
|
->and($runningStep?->operationRun?->type)->toBe(OperationRunType::EvidenceSnapshotGenerate->value)
|
|
->and(AuditLog::query()
|
|
->where('action', AuditActionId::ReviewPublicationResolutionOperationLinked->value)
|
|
->where('resource_id', (string) $updatedCase->getKey())
|
|
->exists())->toBeTrue();
|
|
});
|
|
|
|
it('promotes blocked mutable reviews to the publication resolution page action', function (): void {
|
|
$tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Header']);
|
|
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
$snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
|
|
$review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot);
|
|
|
|
setAdminEnvironmentContext($tenant);
|
|
$expectedUrl = EnvironmentReviewResource::environmentScopedUrl('resolve-publication', ['record' => $review], $tenant);
|
|
|
|
Livewire::actingAs($owner)
|
|
->test(ViewEnvironmentReview::class, ['record' => $review->getKey()])
|
|
->assertActionExists('resolve_publication_blockers', fn (Action $action): bool => ! $action->isConfirmationRequired())
|
|
->assertActionVisible('resolve_publication_blockers')
|
|
->callAction('resolve_publication_blockers')
|
|
->assertRedirect($expectedUrl);
|
|
|
|
expect(ReviewPublicationResolutionCase::query()->forReview($review)->active()->count())->toBe(1);
|
|
});
|