steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value); $newReport = spec388StoredReport($tenant, StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES); app(ReviewPublicationResolutionService::class)->refreshCase($case->fresh(['steps.operationRun', 'environmentReview.evidenceSnapshot.items']), $owner); $step = $step?->fresh(); expect($step?->status)->toBe(ReviewPublicationResolutionStepStatus::Completed->value) ->and($step?->proof_type)->toBe('stored_report') ->and((int) $step?->proof_id)->toBe((int) $newReport->getKey()) ->and(data_get($step?->metadata, 'proof_currentness'))->toBe(ResolutionProofCurrentness::Current->value) ->and(data_get($step?->metadata, 'proof_usability'))->toBe(ResolutionProofUsability::Usable->value) ->and(data_get($step?->metadata, 'proof_reason_code'))->toBe('proof.required_reports_supersede_operation'); $audit = AuditLog::query() ->where('action', AuditActionId::ReviewPublicationResolutionStepCompleted->value) ->where('resource_id', (string) $case->getKey()) ->latest('id') ->firstOrFail(); $auditPayload = json_encode($audit->metadata, JSON_THROW_ON_ERROR); expect($auditPayload)->toContain('proof_currentness', 'proof_usability', 'proof_visibility') ->and($auditPayload)->toContain(ResolutionProofCurrentness::Current->value, ResolutionProofUsability::Usable->value) ->and($auditPayload)->not->toContain('rawGraphPayload', 'access_token', 'secret-token'); }); it('keeps required reports complete when a latest ready report predates a failed direct report operation', function (): void { [$owner, $tenant, $case] = spec388RequiredReportCaseWithFailedOperation(); $failedStep = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value); $failedRun = $failedStep?->operationRun; expect($failedRun)->toBeInstanceOf(OperationRun::class); $readyReport = spec388StoredReport($tenant, StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES); $olderThanRun = ($failedRun->created_at ?? now())->copy()->subMinutes(10); StoredReport::query() ->whereKey($readyReport->getKey()) ->update([ 'generated_at' => $olderThanRun, 'created_at' => $olderThanRun, 'updated_at' => $olderThanRun, ]); $case = app(ReviewPublicationResolutionService::class) ->refreshCase($case->fresh(['steps.operationRun', 'environmentReview.sections', 'environmentReview.evidenceSnapshot.items', 'environmentReview.currentExportReviewPack']), $owner); $reportStep = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value); $evidenceStep = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value); expect($case->current_step_key)->toBe(ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value) ->and($reportStep?->status)->toBe(ReviewPublicationResolutionStepStatus::Completed->value) ->and($reportStep?->proof_type)->toBe('stored_report') ->and((int) $reportStep?->proof_id)->toBe((int) $readyReport->getKey()) ->and(data_get($reportStep?->metadata, 'proof_currentness'))->toBe(ResolutionProofCurrentness::Current->value) ->and(data_get($reportStep?->metadata, 'proof_usability'))->toBe(ResolutionProofUsability::Usable->value) ->and(data_get($reportStep?->metadata, 'proof_reason_code'))->toBe('proof.required_reports_current') ->and($evidenceStep?->status)->toBe(ReviewPublicationResolutionStepStatus::Actionable->value) ->and(data_get($evidenceStep?->metadata, 'proof_reason_code'))->toBe('proof.evidence_stale') ->and(data_get($evidenceStep?->metadata, 'proof_usability'))->toBe(ResolutionProofUsability::NotUsable->value); }); it('does not complete required reports or evidence with a failed latest stored report', function (): void { [$owner, $tenant] = createUserWithTenant(role: 'owner'); $review = spec388ReadyReviewWithPack($tenant, $owner); $section = $review->sections()->firstOrFail(); $summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : []; $summaryPayload['publication_blockers'] = ['Spec388 failed report must not prove currentness.']; $section->forceFill(['summary_payload' => $summaryPayload])->save(); $case = app(ReviewPublicationResolutionService::class)->openOrResume($review->fresh(['sections', 'evidenceSnapshot.items', 'currentExportReviewPack']), $owner); $failedReport = spec388StoredReport($tenant, StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES); $failedReport->forceFill([ 'status' => StoredReport::STATUS_FAILED, 'generated_at' => now()->addMinutes(10), 'created_at' => now()->addMinutes(10), 'updated_at' => now()->addMinutes(10), ])->save(); $review->evidenceSnapshot?->items()->where('dimension_key', 'entra_admin_roles')->update([ 'state' => EvidenceCompletenessState::Complete->value, 'source_record_id' => (int) $failedReport->getKey(), 'source_fingerprint' => (string) $failedReport->fingerprint, ]); $case = app(ReviewPublicationResolutionService::class) ->refreshCase($case->fresh(['steps.operationRun', 'environmentReview.sections', 'environmentReview.evidenceSnapshot.items', 'environmentReview.currentExportReviewPack']), $owner); $reportStep = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value); $evidenceStep = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value); expect($case->current_step_key)->toBe(ReviewPublicationResolutionStepKey::CompleteRequiredReports->value) ->and($reportStep?->status)->toBe(ReviewPublicationResolutionStepStatus::Actionable->value) ->and(data_get($reportStep?->metadata, 'proof_reason_code'))->toBe('proof.required_report_missing') ->and(data_get($reportStep?->metadata, 'proof_usability'))->toBe(ResolutionProofUsability::NotUsable->value) ->and($evidenceStep?->status)->not->toBe(ReviewPublicationResolutionStepStatus::Completed->value) ->and(data_get($evidenceStep?->metadata, 'proof_reason_code'))->toBe('proof.evidence_stale') ->and(data_get($evidenceStep?->metadata, 'proof_usability'))->toBe(ResolutionProofUsability::NotUsable->value); }); it('clears terminal timestamps when completed proof is reopened as stale', function (): void { [$owner, $tenant, $case] = spec388RequiredReportCaseWithFailedOperation(); spec388StoredReport($tenant, StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES); $case = app(ReviewPublicationResolutionService::class)->refreshCase($case->fresh(['steps.operationRun', 'environmentReview.evidenceSnapshot.items']), $owner); $step = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value); expect($step?->status)->toBe(ReviewPublicationResolutionStepStatus::Completed->value) ->and($step?->completed_at)->not->toBeNull(); $failedReport = spec388StoredReport($tenant, StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES); $failedReport->forceFill([ 'status' => StoredReport::STATUS_FAILED, 'generated_at' => now()->addMinutes(10), 'created_at' => now()->addMinutes(10), 'updated_at' => now()->addMinutes(10), ])->save(); $case = app(ReviewPublicationResolutionService::class)->refreshCase($case->fresh(['steps.operationRun', 'environmentReview.evidenceSnapshot.items']), $owner); $step = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value); expect($step?->status)->toBe(ReviewPublicationResolutionStepStatus::Actionable->value) ->and($step?->completed_at)->toBeNull() ->and($step?->failed_at)->toBeNull() ->and($step?->started_at)->toBeNull(); }); it('keeps successful operation proof inspection-only when the expected artifact is missing', function (): void { [$owner, $tenant, $case] = spec388RequiredReportCaseWithFailedOperation(); $step = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value); $run = OperationRun::factory()->forTenant($tenant)->create([ 'type' => OperationRunType::EntraAdminRolesScan->value, 'status' => OperationRunStatus::Completed->value, 'outcome' => OperationRunOutcome::Succeeded->value, 'completed_at' => now(), 'context' => [ 'environment_review_id' => (int) $case->environment_review_id, 'review_publication_resolution_case_id' => (int) $case->getKey(), ], ]); $step?->forceFill([ 'status' => ReviewPublicationResolutionStepStatus::Running->value, 'operation_run_id' => (int) $run->getKey(), 'proof_type' => 'operation_run', 'proof_id' => (int) $run->getKey(), 'proof_status' => OperationRunOutcome::Succeeded->value, 'metadata' => array_replace(is_array($step?->metadata) ? $step->metadata : [], [ 'readiness_fingerprint' => (string) $case->readiness_fingerprint, ]), ])->save(); app(ReviewPublicationResolutionService::class)->refreshCase($case->fresh(['steps.operationRun', 'environmentReview.evidenceSnapshot.items']), $owner); $step = $step?->fresh(); expect($step?->status)->not->toBe(ReviewPublicationResolutionStepStatus::Completed->value) ->and(data_get($step?->metadata, 'proof_usability'))->toBe(ResolutionProofUsability::InspectionOnly->value) ->and(data_get($step?->metadata, 'proof_reason_code'))->toBe('proof.operation_succeeded_without_artifact'); }); it('keeps running operation proof inspection-only for the matching current step', function (): void { [$owner, $tenant, $case] = spec388RequiredReportCaseWithFailedOperation(); $step = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value); $run = OperationRun::factory()->forTenant($tenant)->create([ 'type' => OperationRunType::EntraAdminRolesScan->value, 'status' => OperationRunStatus::Running->value, 'outcome' => OperationRunOutcome::Pending->value, 'started_at' => now(), 'context' => [ 'environment_review_id' => (int) $case->environment_review_id, 'review_publication_resolution_case_id' => (int) $case->getKey(), ], ]); $step?->forceFill([ 'status' => ReviewPublicationResolutionStepStatus::Running->value, 'operation_run_id' => (int) $run->getKey(), 'proof_type' => 'operation_run', 'proof_id' => (int) $run->getKey(), 'proof_status' => OperationRunStatus::Running->value, 'metadata' => array_replace(is_array($step?->metadata) ? $step->metadata : [], [ 'readiness_fingerprint' => (string) $case->readiness_fingerprint, ]), ])->save(); app(ReviewPublicationResolutionService::class)->refreshCase($case->fresh(['steps.operationRun', 'environmentReview.evidenceSnapshot.items']), $owner); $step = $step?->fresh(); expect($step?->status)->toBe(ReviewPublicationResolutionStepStatus::Running->value) ->and(data_get($step?->metadata, 'proof_currentness'))->toBe(ResolutionProofCurrentness::Current->value) ->and(data_get($step?->metadata, 'proof_usability'))->toBe(ResolutionProofUsability::InspectionOnly->value) ->and(data_get($step?->metadata, 'proof_reason_code'))->toBe('proof.operation_running'); }); it('does not keep a same-type operation without review context running for the current step', function (): void { [$owner, $tenant, $case] = spec388RequiredReportCaseWithFailedOperation(); $step = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value); $run = OperationRun::factory()->forTenant($tenant)->create([ 'type' => OperationRunType::EntraAdminRolesScan->value, 'status' => OperationRunStatus::Running->value, 'outcome' => OperationRunOutcome::Pending->value, 'started_at' => now(), 'context' => [], ]); $step?->forceFill([ 'status' => ReviewPublicationResolutionStepStatus::Running->value, 'operation_run_id' => (int) $run->getKey(), 'proof_type' => 'operation_run', 'proof_id' => (int) $run->getKey(), 'proof_status' => OperationRunStatus::Running->value, 'metadata' => array_replace(is_array($step?->metadata) ? $step->metadata : [], [ 'readiness_fingerprint' => (string) $case->readiness_fingerprint, ]), ])->save(); app(ReviewPublicationResolutionService::class)->refreshCase($case->fresh(['steps.operationRun', 'environmentReview.evidenceSnapshot.items']), $owner); $step = $step?->fresh(); expect($step?->status)->toBe(ReviewPublicationResolutionStepStatus::Actionable->value) ->and($step?->operation_run_id)->toBeNull() ->and($step?->proof_type)->toBeNull() ->and(data_get($step?->metadata, 'proof_reason_code'))->toBe('proof.operation_context_missing') ->and(data_get($step?->metadata, 'proof_usability'))->toBe(ResolutionProofUsability::NotUsable->value); }); it('rejects malformed operation context ids for the current step', function (): void { [$owner, $tenant, $case] = spec388RequiredReportCaseWithFailedOperation(); $step = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value); $run = OperationRun::factory()->forTenant($tenant)->create([ 'type' => OperationRunType::EntraAdminRolesScan->value, 'status' => OperationRunStatus::Running->value, 'outcome' => OperationRunOutcome::Pending->value, 'started_at' => now(), 'context' => [ 'environment_review_id' => 'not-a-review-id', ], ]); $step?->forceFill([ 'status' => ReviewPublicationResolutionStepStatus::Running->value, 'operation_run_id' => (int) $run->getKey(), 'proof_type' => 'operation_run', 'proof_id' => (int) $run->getKey(), 'proof_status' => OperationRunStatus::Running->value, 'metadata' => array_replace(is_array($step?->metadata) ? $step->metadata : [], [ 'readiness_fingerprint' => (string) $case->readiness_fingerprint, ]), ])->save(); app(ReviewPublicationResolutionService::class)->refreshCase($case->fresh(['steps.operationRun', 'environmentReview.evidenceSnapshot.items']), $owner); $step = $step?->fresh(); expect($step?->status)->toBe(ReviewPublicationResolutionStepStatus::Actionable->value) ->and($step?->operation_run_id)->toBeNull() ->and($step?->proof_type)->toBeNull() ->and(data_get($step?->metadata, 'proof_reason_code'))->toBe('proof.operation_context_mismatch') ->and(data_get($step?->metadata, 'proof_usability'))->toBe(ResolutionProofUsability::NotUsable->value); }); it('rejects operation context with conflicting review id aliases for the current step', function (): void { [$owner, $tenant, $case] = spec388RequiredReportCaseWithFailedOperation(); $step = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value); $run = OperationRun::factory()->forTenant($tenant)->create([ 'type' => OperationRunType::EntraAdminRolesScan->value, 'status' => OperationRunStatus::Running->value, 'outcome' => OperationRunOutcome::Pending->value, 'started_at' => now(), 'context' => [ 'environment_review_id' => (int) $case->environment_review_id, 'review_id' => (int) $case->environment_review_id + 999, 'review_publication_resolution_case_id' => (int) $case->getKey(), ], ]); $step?->forceFill([ 'status' => ReviewPublicationResolutionStepStatus::Running->value, 'operation_run_id' => (int) $run->getKey(), 'proof_type' => 'operation_run', 'proof_id' => (int) $run->getKey(), 'proof_status' => OperationRunStatus::Running->value, 'metadata' => array_replace(is_array($step?->metadata) ? $step->metadata : [], [ 'readiness_fingerprint' => (string) $case->readiness_fingerprint, ]), ])->save(); app(ReviewPublicationResolutionService::class)->refreshCase($case->fresh(['steps.operationRun', 'environmentReview.evidenceSnapshot.items']), $owner); $step = $step?->fresh(); expect($step?->status)->toBe(ReviewPublicationResolutionStepStatus::Actionable->value) ->and($step?->operation_run_id)->toBeNull() ->and($step?->proof_type)->toBeNull() ->and(data_get($step?->metadata, 'proof_reason_code'))->toBe('proof.operation_context_mismatch') ->and(data_get($step?->metadata, 'proof_usability'))->toBe(ResolutionProofUsability::NotUsable->value); }); it('does not keep an unrelated same-scope operation running for the current step', function (): void { [$owner, $tenant, $case] = spec388RequiredReportCaseWithFailedOperation(); $step = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value); $run = OperationRun::factory()->forTenant($tenant)->create([ 'type' => OperationRunType::ReviewPackGenerate->value, 'status' => OperationRunStatus::Running->value, 'outcome' => OperationRunOutcome::Pending->value, 'started_at' => now(), ]); $step?->forceFill([ 'status' => ReviewPublicationResolutionStepStatus::Running->value, 'operation_run_id' => (int) $run->getKey(), 'proof_type' => 'operation_run', 'proof_id' => (int) $run->getKey(), 'proof_status' => OperationRunStatus::Running->value, 'metadata' => array_replace(is_array($step?->metadata) ? $step->metadata : [], [ 'readiness_fingerprint' => (string) $case->readiness_fingerprint, ]), ])->save(); app(ReviewPublicationResolutionService::class)->refreshCase($case->fresh(['steps.operationRun', 'environmentReview.evidenceSnapshot.items']), $owner); $step = $step?->fresh(); expect($step?->status)->toBe(ReviewPublicationResolutionStepStatus::Actionable->value) ->and($step?->operation_run_id)->toBeNull() ->and($step?->proof_type)->toBeNull() ->and(data_get($step?->metadata, 'proof_reason_code'))->toBe('proof.operation_type_mismatch') ->and(data_get($step?->metadata, 'proof_usability'))->toBe(ResolutionProofUsability::NotUsable->value); }); it('fails closed when any active provider connection check belongs to another resolution context', function (): void { Queue::fake(); [$owner, $tenant, $case] = spec388PermissionPostureRequiredReportCase(); $connection = ProviderConnection::query() ->where('managed_environment_id', (int) $tenant->getKey()) ->where('provider', 'microsoft') ->where('is_default', true) ->firstOrFail(); $matchingRun = OperationRun::factory()->forTenant($tenant)->create([ 'type' => 'provider.connection.check', 'status' => OperationRunStatus::Running->value, 'outcome' => OperationRunOutcome::Pending->value, 'started_at' => now()->subMinute(), 'context' => [ 'workspace_id' => (int) $tenant->workspace_id, 'provider_connection_id' => (int) $connection->getKey(), 'trigger' => 'review_publication_resolution', 'environment_review_id' => (int) $case->environment_review_id, 'review_publication_resolution_case_id' => (int) $case->getKey(), ], ]); $foreignRun = OperationRun::factory()->forTenant($tenant)->create([ 'type' => 'provider.connection.check', 'status' => OperationRunStatus::Running->value, 'outcome' => OperationRunOutcome::Pending->value, 'started_at' => now(), 'context' => [ 'workspace_id' => (int) $tenant->workspace_id, 'provider_connection_id' => (int) $connection->getKey(), 'trigger' => 'manual_check', ], ]); expect(fn () => app(\App\Support\ReviewPublicationResolution\ReviewPublicationResolutionActionService::class) ->executeCurrentStep($case->fresh(['steps.operationRun', 'environmentReview.tenant']), $owner)) ->toThrow(InvalidArgumentException::class, 'Provider connection check is already running'); $step = $case->fresh(['steps.operationRun'])->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value); expect($step?->status)->toBe(ReviewPublicationResolutionStepStatus::Failed->value) ->and($step?->operation_run_id)->toBeNull() ->and(OperationRun::query() ->where('managed_environment_id', (int) $tenant->getKey()) ->where('type', 'provider.connection.check') ->where('context->provider_connection_id', (int) $connection->getKey()) ->count())->toBe(2) ->and(AuditLog::query() ->where('action', AuditActionId::ReviewPublicationResolutionOperationLinked->value) ->where('resource_id', (string) $case->getKey()) ->exists())->toBeFalse() ->and($matchingRun->fresh()?->status)->toBe(OperationRunStatus::Running->value) ->and($foreignRun->fresh()?->status)->toBe(OperationRunStatus::Running->value); Queue::assertNotPushed(ProviderConnectionHealthCheckJob::class); }); it('dedupes an active provider connection check when it matches the current resolution context', function (): void { Queue::fake(); [$owner, $tenant, $case] = spec388PermissionPostureRequiredReportCase(); $connection = ProviderConnection::query() ->where('managed_environment_id', (int) $tenant->getKey()) ->where('provider', 'microsoft') ->where('is_default', true) ->firstOrFail(); $matchingRun = OperationRun::factory()->forTenant($tenant)->create([ 'type' => 'provider.connection.check', 'status' => OperationRunStatus::Running->value, 'outcome' => OperationRunOutcome::Pending->value, 'started_at' => now(), 'context' => [ 'workspace_id' => (int) $tenant->workspace_id, 'provider_connection_id' => (int) $connection->getKey(), 'trigger' => 'review_publication_resolution', 'environment_review_id' => (int) $case->environment_review_id, 'review_publication_resolution_case_id' => (int) $case->getKey(), ], ]); $result = app(\App\Support\ReviewPublicationResolution\ReviewPublicationResolutionActionService::class) ->executeCurrentStep($case->fresh(['steps.operationRun', 'environmentReview.tenant']), $owner); $step = $result['step']->fresh('operationRun'); expect($result['operation_run'])->toBeInstanceOf(OperationRun::class) ->and((int) $result['operation_run']?->getKey())->toBe((int) $matchingRun->getKey()) ->and($step?->status)->toBe(ReviewPublicationResolutionStepStatus::Running->value) ->and((int) $step?->operation_run_id)->toBe((int) $matchingRun->getKey()); Queue::assertNotPushed(ProviderConnectionHealthCheckJob::class); }); it('keeps failed operation proof inspection-only when no current artifact exists', function (): void { [$owner, $tenant, $case] = spec388RequiredReportCaseWithFailedOperation(); $step = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value); app(ReviewPublicationResolutionService::class)->refreshCase($case->fresh(['steps.operationRun', 'environmentReview.evidenceSnapshot.items']), $owner); $step = $step?->fresh(); expect($step?->status)->toBe(ReviewPublicationResolutionStepStatus::Failed->value) ->and(data_get($step?->metadata, 'proof_usability'))->toBe(ResolutionProofUsability::InspectionOnly->value) ->and(data_get($step?->metadata, 'proof_reason_code'))->toBe('proof.operation_terminal_without_current_artifact') ->and($step?->proof_type)->toBe('operation_run'); }); it('does not link a reused active Entra scan from another resolution context', function (): void { Queue::fake(); [$owner, $tenant, $case] = spec388RequiredReportCaseWithFailedOperation(); $oldRun = app(OperationRunService::class)->ensureRunWithIdentity( tenant: $tenant, type: OperationRunType::EntraAdminRolesScan->value, identityInputs: [ 'managed_environment_id' => (int) $tenant->getKey(), 'trigger' => 'scan', ], context: [ 'managed_environment_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'trigger' => 'manual_scan', ], initiator: $owner, ); $result = app(\App\Support\ReviewPublicationResolution\ReviewPublicationResolutionActionService::class) ->executeCurrentStep($case->fresh(['steps.operationRun', 'environmentReview.tenant']), $owner); $run = $result['operation_run']; expect($run)->toBeInstanceOf(OperationRun::class) ->and((int) $run?->getKey())->not->toBe((int) $oldRun->getKey()) ->and(data_get($run?->context, 'environment_review_id'))->toBe((int) $case->environment_review_id) ->and(data_get($run?->context, 'review_publication_resolution_case_id'))->toBe((int) $case->getKey()); }); it('hides cross-scope operation proof instead of using it for the current step', function (): void { [$owner, $tenant, $case] = spec388RequiredReportCaseWithFailedOperation(); $step = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value); $otherTenant = ManagedEnvironment::factory()->create(['name' => 'Spec388 Other Scope']); $run = OperationRun::factory()->forTenant($otherTenant)->create([ 'type' => OperationRunType::EntraAdminRolesScan->value, 'status' => OperationRunStatus::Completed->value, 'outcome' => OperationRunOutcome::Failed->value, 'completed_at' => now(), ]); $step?->forceFill([ 'status' => ReviewPublicationResolutionStepStatus::Failed->value, 'operation_run_id' => (int) $run->getKey(), 'proof_type' => 'operation_run', 'proof_id' => (int) $run->getKey(), 'proof_status' => OperationRunOutcome::Failed->value, ])->save(); app(ReviewPublicationResolutionService::class)->refreshCase($case->fresh(['steps.operationRun', 'environmentReview.evidenceSnapshot.items']), $owner); $step = $step?->fresh(); expect(data_get($step?->metadata, 'proof_visibility'))->toBe(ResolutionProofVisibility::Hidden->value) ->and(data_get($step?->metadata, 'proof_reason_code'))->toBe('proof.operation_scope_mismatch') ->and($step?->proof_type)->toBeNull() ->and($step?->proof_id)->toBeNull(); }); it('does not leak a cross-scope operation id when current reports exist', function (): void { [$owner, $tenant, $case] = spec388RequiredReportCaseWithFailedOperation(); $step = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value); $otherTenant = ManagedEnvironment::factory()->create(['name' => 'Spec388 Other Superseded Scope']); $run = OperationRun::factory()->forTenant($otherTenant)->create([ 'type' => OperationRunType::EntraAdminRolesScan->value, 'status' => OperationRunStatus::Completed->value, 'outcome' => OperationRunOutcome::Failed->value, 'completed_at' => now(), ]); $step?->forceFill([ 'status' => ReviewPublicationResolutionStepStatus::Failed->value, 'operation_run_id' => (int) $run->getKey(), 'proof_type' => 'operation_run', 'proof_id' => (int) $run->getKey(), 'proof_status' => OperationRunOutcome::Failed->value, ])->save(); spec388StoredReport($tenant, StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES); app(ReviewPublicationResolutionService::class)->refreshCase($case->fresh(['steps.operationRun', 'environmentReview.evidenceSnapshot.items']), $owner); $step = $step?->fresh(); $metadataPayload = json_encode($step?->metadata ?? [], JSON_THROW_ON_ERROR); expect(data_get($step?->metadata, 'proof_visibility'))->toBe(ResolutionProofVisibility::Hidden->value) ->and(data_get($step?->metadata, 'proof_reason_code'))->toBe('proof.operation_scope_mismatch') ->and($step?->proof_type)->toBeNull() ->and($step?->proof_id)->toBeNull() ->and($metadataPayload)->not->toContain((string) $run->getKey(), 'superseded_operation_run_id'); }); it('does not reuse previously superseded operation metadata from another resolution context', function (): void { [$owner, $tenant, $case] = spec388RequiredReportCaseWithFailedOperation(); $step = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value); $otherRun = OperationRun::factory()->forTenant($tenant)->create([ 'type' => OperationRunType::EntraAdminRolesScan->value, 'status' => OperationRunStatus::Completed->value, 'outcome' => OperationRunOutcome::Failed->value, 'completed_at' => now()->subMinute(), 'context' => [ 'environment_review_id' => (int) $case->environment_review_id, 'review_publication_resolution_case_id' => (int) $case->getKey() + 1000, ], ]); $step?->forceFill([ 'status' => ReviewPublicationResolutionStepStatus::Completed->value, 'operation_run_id' => null, 'proof_type' => 'stored_report', 'proof_id' => null, 'proof_status' => StoredReport::STATUS_READY, 'metadata' => array_replace(is_array($step?->metadata) ? $step->metadata : [], [ 'proof_reason_code' => 'proof.required_reports_supersede_operation', 'proof_summary' => [ 'superseded_operation_run_id' => (int) $otherRun->getKey(), ], ]), ])->save(); spec388StoredReport($tenant, StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES); app(ReviewPublicationResolutionService::class)->refreshCase($case->fresh(['steps.operationRun', 'environmentReview.evidenceSnapshot.items']), $owner); $step = $step?->fresh(); $metadataPayload = json_encode($step?->metadata ?? [], JSON_THROW_ON_ERROR); expect($step?->status)->toBe(ReviewPublicationResolutionStepStatus::Completed->value) ->and(data_get($step?->metadata, 'proof_reason_code'))->toBe('proof.required_reports_current') ->and($metadataPayload)->not->toContain((string) $otherRun->getKey(), 'superseded_operation_run_id'); }); it('marks evidence proof stale when required reports changed after collection', function (): void { [$owner, $tenant] = createUserWithTenant(role: 'owner'); $snapshot = seedEnvironmentReviewEvidence($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, ]); $review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot->fresh('items')); spec388StoredReport($tenant, StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES); $case = app(ReviewPublicationResolutionService::class)->openOrResume($review->fresh(['evidenceSnapshot.items']), $owner); $step = $case?->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value); expect($case)->toBeInstanceOf(ReviewPublicationResolutionCase::class) ->and($step)->not->toBeNull() ->and($step?->status)->toBe(ReviewPublicationResolutionStepStatus::Actionable->value) ->and(data_get($step?->metadata, 'proof_currentness'))->toBe(ResolutionProofCurrentness::Stale->value) ->and(data_get($step?->metadata, 'proof_reason_code'))->toBe('proof.evidence_stale'); }); it('routes a missing snapshot report reference to evidence collection when current reports already exist', function (): void { $tenant = ManagedEnvironment::factory()->create(['name' => 'Spec388 Required Reports Current Snapshot Missing']); [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); $snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); $permissionReport = StoredReport::query() ->where('managed_environment_id', (int) $tenant->getKey()) ->where('report_type', StoredReport::REPORT_TYPE_PERMISSION_POSTURE) ->latest('id') ->firstOrFail(); $adminRolesReport = StoredReport::query() ->where('managed_environment_id', (int) $tenant->getKey()) ->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES) ->latest('id') ->firstOrFail(); $snapshot = restateEnvironmentReviewEvidenceSnapshot($snapshot, EvidenceCompletenessState::Complete); $snapshot->items()->where('dimension_key', 'permission_posture')->update([ 'state' => EvidenceCompletenessState::Missing->value, 'source_record_id' => null, 'source_fingerprint' => null, ]); $snapshot->items()->where('dimension_key', 'entra_admin_roles')->update([ 'state' => EvidenceCompletenessState::Complete->value, 'source_record_id' => (int) $adminRolesReport->getKey(), 'source_fingerprint' => (string) $adminRolesReport->fingerprint, ]); $review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot->fresh('items')); $case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner); $stepKeys = $case?->steps->pluck('step_key')->all(); $evidenceStep = $case?->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value); expect($permissionReport->status)->toBe(StoredReport::STATUS_READY) ->and($case?->current_step_key)->toBe(ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value) ->and($stepKeys)->not->toContain(ReviewPublicationResolutionStepKey::CompleteRequiredReports->value) ->and($evidenceStep?->status)->toBe(ReviewPublicationResolutionStepStatus::Actionable->value) ->and(data_get($evidenceStep?->metadata, 'proof_currentness'))->toBe(ResolutionProofCurrentness::Stale->value) ->and(data_get($evidenceStep?->metadata, 'proof_reason_code'))->toBe('proof.evidence_stale'); }); it('keeps the required report action first when a required report is genuinely missing', function (): void { [$owner, $tenant, $case] = spec388PermissionPostureRequiredReportCase(); $stepKeys = $case->steps->pluck('step_key')->all(); $reportStep = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value); expect(StoredReport::query() ->where('managed_environment_id', (int) $tenant->getKey()) ->where('report_type', StoredReport::REPORT_TYPE_PERMISSION_POSTURE) ->where('status', StoredReport::STATUS_READY) ->exists())->toBeFalse() ->and($owner)->toBeInstanceOf(User::class) ->and($case->current_step_key)->toBe(ReviewPublicationResolutionStepKey::CompleteRequiredReports->value) ->and($stepKeys)->toContain(ReviewPublicationResolutionStepKey::CompleteRequiredReports->value) ->and($reportStep?->status)->toBe(ReviewPublicationResolutionStepStatus::Actionable->value) ->and(data_get($reportStep?->metadata, 'proof_reason_code'))->toBe('proof.required_report_missing'); }); it('does not reopen required reports when newer ready reports make existing evidence stale', function (): void { $tenant = ManagedEnvironment::factory()->create(['name' => 'Spec388 Report Step Ordering']); [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); $snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); $permissionReport = StoredReport::query() ->where('managed_environment_id', (int) $tenant->getKey()) ->where('report_type', StoredReport::REPORT_TYPE_PERMISSION_POSTURE) ->latest('id') ->firstOrFail(); $snapshot->items()->where('dimension_key', 'permission_posture')->update([ 'state' => EvidenceCompletenessState::Complete->value, 'source_record_id' => (int) $permissionReport->getKey(), 'source_fingerprint' => (string) $permissionReport->fingerprint, ]); $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->fresh('items')); $case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner); $adminRolesReport = spec388StoredReport($tenant, StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES); $snapshot = restateEnvironmentReviewEvidenceSnapshot($snapshot->fresh('items'), EvidenceCompletenessState::Complete); $snapshot->items()->where('dimension_key', 'permission_posture')->update([ 'state' => EvidenceCompletenessState::Complete->value, 'source_record_id' => (int) $permissionReport->getKey(), 'source_fingerprint' => (string) $permissionReport->fingerprint, ]); $snapshot->items()->where('dimension_key', 'entra_admin_roles')->update([ 'state' => EvidenceCompletenessState::Complete->value, 'source_record_id' => (int) $adminRolesReport->getKey(), 'source_fingerprint' => (string) $adminRolesReport->fingerprint, ]); $case = app(ReviewPublicationResolutionService::class) ->refreshCase($case->fresh(['steps.operationRun', 'environmentReview.evidenceSnapshot.items']), $owner); $initialReportStep = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value); expect($initialReportStep === null || $initialReportStep->status === ReviewPublicationResolutionStepStatus::Completed->value) ->toBeTrue(); spec388StoredReport($tenant, StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES); $case = app(ReviewPublicationResolutionService::class) ->refreshCase($case->fresh(['steps.operationRun', 'environmentReview.evidenceSnapshot.items']), $owner); $reportStep = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value); $evidenceStep = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value); expect($case->current_step_key)->toBe(ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value) ->and($reportStep === null || $reportStep->status === ReviewPublicationResolutionStepStatus::Completed->value)->toBeTrue() ->and($reportStep === null || data_get($reportStep?->metadata, 'proof_reason_code') === 'proof.required_reports_current')->toBeTrue() ->and($evidenceStep?->status)->toBe(ReviewPublicationResolutionStepStatus::Actionable->value) ->and(data_get($evidenceStep?->metadata, 'proof_reason_code'))->toBe('proof.evidence_stale'); }); it('marks evidence proof stale when a newer report ties the snapshot timestamp', function (): void { [$owner, $tenant] = createUserWithTenant(role: 'owner'); $review = spec388ReadyReviewWithPack($tenant, $owner); $section = $review->sections()->firstOrFail(); $summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : []; $summaryPayload['publication_blockers'] = ['Spec388 temporary case-opening blocker.']; $section->forceFill(['summary_payload' => $summaryPayload])->save(); $case = app(ReviewPublicationResolutionService::class)->openOrResume($review->fresh(['sections', 'evidenceSnapshot.items', 'currentExportReviewPack']), $owner); spec388ClearReviewPublicationBlockers($review->fresh('sections')); $snapshot = $review->evidenceSnapshot?->fresh('items'); $timestamp = now()->addMinutes(5); $snapshot?->forceFill([ 'generated_at' => $timestamp, 'created_at' => $timestamp, 'updated_at' => $timestamp, ])->save(); $newReport = spec388StoredReport($tenant, StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES); $newReport->forceFill([ 'generated_at' => $timestamp, 'created_at' => $timestamp, 'updated_at' => $timestamp, ])->save(); $case = app(ReviewPublicationResolutionService::class)->refreshCase($case->fresh(['steps.operationRun', 'environmentReview.sections', 'environmentReview.evidenceSnapshot.items', 'environmentReview.currentExportReviewPack']), $owner); $step = $case?->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value); expect($step)->not->toBeNull() ->and($step?->status)->toBe(ReviewPublicationResolutionStepStatus::Actionable->value) ->and(data_get($step?->metadata, 'proof_currentness'))->toBe(ResolutionProofCurrentness::Stale->value) ->and(data_get($step?->metadata, 'proof_reason_code'))->toBe('proof.evidence_stale'); }); it('requires review recomposition when current evidence becomes stale after a report update', function (): void { [$owner, $tenant] = createUserWithTenant(role: 'owner'); $review = spec388ReadyReviewWithPack($tenant, $owner); $section = $review->sections()->firstOrFail(); $summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : []; $summaryPayload['publication_blockers'] = ['Spec388 temporary case-opening blocker.']; $section->forceFill(['summary_payload' => $summaryPayload])->save(); $case = app(ReviewPublicationResolutionService::class)->openOrResume($review->fresh(['sections', 'evidenceSnapshot.items', 'currentExportReviewPack']), $owner); spec388ClearReviewPublicationBlockers($review->fresh('sections')); spec388StoredReport($tenant, StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES); $case = app(ReviewPublicationResolutionService::class)->refreshCase($case->fresh(['steps.operationRun', 'environmentReview.sections', 'environmentReview.evidenceSnapshot.items', 'environmentReview.currentExportReviewPack']), $owner); $collectStep = $case?->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value); $refreshStep = $case?->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::RefreshReviewComposition->value); expect($case?->current_step_key)->toBe(ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value) ->and($collectStep?->status)->toBe(ReviewPublicationResolutionStepStatus::Actionable->value) ->and($refreshStep)->not->toBeNull() ->and($refreshStep?->status)->toBe(ReviewPublicationResolutionStepStatus::Pending->value) ->and(data_get($refreshStep?->metadata, 'proof_currentness'))->toBe(ResolutionProofCurrentness::Stale->value) ->and(data_get($refreshStep?->metadata, 'proof_reason_code'))->toBe('proof.review_output_stale'); }); it('keeps executed evidence collection running after case refresh', function (): void { Queue::fake(); [$owner, $tenant] = createUserWithTenant(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(\App\Support\ReviewPublicationResolution\ReviewPublicationResolutionActionService::class) ->executeCurrentStep($case, $owner); $case = app(ReviewPublicationResolutionService::class) ->refreshCase($result['case']->fresh(['steps.operationRun', 'environmentReview.evidenceSnapshot.items']), $owner); $step = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value); expect($case->status)->toBe(ReviewPublicationResolutionCaseStatus::WaitingForRun->value) ->and($case->current_step_key)->toBe(ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value) ->and($step?->status)->toBe(ReviewPublicationResolutionStepStatus::Running->value) ->and(data_get($step?->metadata, 'proof_reason_code'))->toBe('proof.operation_running'); }); it('keeps source-owned review refresh operation running without matching readiness fingerprint', function (): void { [$owner, $tenant] = createUserWithTenant(role: 'owner'); $review = spec388ReadyReviewWithPack($tenant, $owner); $section = $review->sections()->firstOrFail(); $summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : []; $summaryPayload['publication_blockers'] = ['Spec388 review changed before refresh execution.']; $section->forceFill([ 'completeness_state' => \App\Support\EnvironmentReviewCompletenessState::Partial->value, 'summary_payload' => $summaryPayload, ])->save(); $case = app(ReviewPublicationResolutionService::class)->openOrResume($review->fresh(['sections', 'evidenceSnapshot.items', 'currentExportReviewPack']), $owner); $step = $case?->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::RefreshReviewComposition->value); $run = OperationRun::factory()->forTenant($tenant)->create([ 'type' => OperationRunType::EnvironmentReviewCompose->value, 'status' => OperationRunStatus::Running->value, 'outcome' => OperationRunOutcome::Pending->value, 'started_at' => now(), 'context' => [ 'review_id' => (int) $review->getKey(), 'evidence_snapshot_id' => (int) $review->evidence_snapshot_id, ], ]); $review->forceFill(['operation_run_id' => (int) $run->getKey()])->save(); $step?->forceFill([ 'status' => ReviewPublicationResolutionStepStatus::Running->value, 'operation_run_id' => (int) $run->getKey(), 'proof_type' => 'environment_review', 'proof_id' => (int) $review->getKey(), 'proof_status' => OperationRunStatus::Running->value, 'metadata' => array_replace(is_array($step?->metadata) ? $step->metadata : [], [ 'readiness_fingerprint' => 'spec388-stale-fingerprint', ]), ])->save(); $case = app(ReviewPublicationResolutionService::class) ->refreshCase($case->fresh(['steps.operationRun', 'environmentReview.sections', 'environmentReview.evidenceSnapshot.items']), $owner); $step = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::RefreshReviewComposition->value); expect($case->status)->toBe(ReviewPublicationResolutionCaseStatus::WaitingForRun->value) ->and($case->current_step_key)->toBe(ReviewPublicationResolutionStepKey::RefreshReviewComposition->value) ->and($step?->status)->toBe(ReviewPublicationResolutionStepStatus::Running->value) ->and(data_get($step?->metadata, 'proof_reason_code'))->toBe('proof.operation_running'); }); it('keeps review pack proof stale when the current export is no longer ready', function (): void { [$owner, $tenant] = createUserWithTenant(role: 'owner'); $review = spec388ReadyReviewWithPack($tenant, $owner); $section = $review->sections()->firstOrFail(); $summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : []; $summaryPayload['publication_blockers'] = ['Spec388 temporary case-opening blocker.']; $section->forceFill(['summary_payload' => $summaryPayload])->save(); $case = app(ReviewPublicationResolutionService::class)->openOrResume($review->fresh(['sections', 'evidenceSnapshot.items', 'currentExportReviewPack']), $owner); spec388ClearReviewPublicationBlockers($review->fresh('sections')); $review->currentExportReviewPack?->forceFill([ 'status' => ReviewPackStatus::Expired->value, 'expires_at' => now()->subMinute(), 'file_path' => null, 'file_disk' => null, ])->save(); $case = app(ReviewPublicationResolutionService::class)->refreshCase($case->fresh(['steps.operationRun', 'environmentReview.sections', 'environmentReview.currentExportReviewPack']), $owner); $step = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::GenerateReviewPack->value); expect($step)->not->toBeNull() ->and($step?->status)->toBe(ReviewPublicationResolutionStepStatus::Actionable->value) ->and(data_get($step?->metadata, 'proof_currentness'))->toBe(ResolutionProofCurrentness::Stale->value) ->and(data_get($step?->metadata, 'proof_reason_code'))->toBe('proof.review_pack_stale'); }); it('marks review pack proof stale when review output changes after export', function (): void { [$owner, $tenant] = createUserWithTenant(role: 'owner'); $review = spec388ReadyReviewWithPack($tenant, $owner); $section = $review->sections()->firstOrFail(); $summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : []; $summaryPayload['publication_blockers'] = ['Spec388 review changed after export.']; $section->forceFill([ 'completeness_state' => \App\Support\EnvironmentReviewCompletenessState::Partial->value, 'summary_payload' => $summaryPayload, ])->save(); $case = app(ReviewPublicationResolutionService::class)->openOrResume($review->fresh(['sections', 'evidenceSnapshot.items', 'currentExportReviewPack']), $owner); $refreshStep = $case?->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::RefreshReviewComposition->value); $packStep = $case?->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::GenerateReviewPack->value); expect($case?->current_step_key)->toBe(ReviewPublicationResolutionStepKey::RefreshReviewComposition->value) ->and($refreshStep?->status)->toBe(ReviewPublicationResolutionStepStatus::Actionable->value) ->and($packStep)->not->toBeNull() ->and($packStep?->status)->toBe(ReviewPublicationResolutionStepStatus::Pending->value) ->and(data_get($packStep?->metadata, 'proof_currentness'))->toBe(ResolutionProofCurrentness::Stale->value) ->and(data_get($packStep?->metadata, 'proof_reason_code'))->toBe('proof.review_pack_stale'); }); it('requires a new review pack when the review fingerprint changes without publication blockers', function (): void { [$owner, $tenant] = createUserWithTenant(role: 'owner'); $review = spec388ReadyReviewWithPack($tenant, $owner); $section = $review->sections()->firstOrFail(); $summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : []; $summaryPayload['publication_blockers'] = ['Spec388 temporary case-opening blocker.']; $section->forceFill(['summary_payload' => $summaryPayload])->save(); $case = app(ReviewPublicationResolutionService::class)->openOrResume($review->fresh(['sections', 'evidenceSnapshot.items', 'currentExportReviewPack']), $owner); spec388ClearReviewPublicationBlockers($review->fresh('sections')); $review->forceFill([ 'fingerprint' => hash('sha256', 'spec388-review-output-changed-without-blockers'), ])->save(); $case = app(ReviewPublicationResolutionService::class)->refreshCase($case->fresh(['steps.operationRun', 'environmentReview.sections', 'environmentReview.evidenceSnapshot.items', 'environmentReview.currentExportReviewPack']), $owner); $packStep = $case?->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::GenerateReviewPack->value); expect($case?->current_step_key)->toBe(ReviewPublicationResolutionStepKey::GenerateReviewPack->value) ->and($packStep)->not->toBeNull() ->and($packStep?->status)->toBe(ReviewPublicationResolutionStepStatus::Actionable->value) ->and(data_get($packStep?->metadata, 'proof_currentness'))->toBe(ResolutionProofCurrentness::Stale->value) ->and(data_get($packStep?->metadata, 'proof_reason_code'))->toBe('proof.review_pack_stale'); }); it('shows normalized proof states in the secondary technical disclosure', function (): void { [$owner, $tenant, $case] = spec388RequiredReportCaseWithFailedOperation(); spec388StoredReport($tenant, StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES); app(ReviewPublicationResolutionService::class)->refreshCase($case->fresh(['steps.operationRun', 'environmentReview.evidenceSnapshot.items']), $owner); setAdminEnvironmentContext($tenant); Livewire::actingAs($owner) ->test(ResolveReviewPublication::class, ['record' => $case->environment_review_id]) ->assertSee('Technical proof and operation history') ->assertSee('Current proof') ->assertSee('Superseded by newer result'); }); it('keeps customer-facing review workspace free of internal proof mechanics', function (): void { [$owner, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager'); $review = spec388ReadyReviewWithPack($tenant, $owner); $review->forceFill([ 'status' => EnvironmentReviewStatus::Published->value, 'published_at' => now(), 'published_by_user_id' => (int) $owner->getKey(), ])->save(); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); setAdminPanelContext(); Livewire::actingAs($owner) ->test(CustomerReviewWorkspace::class) ->assertSee('Customer Review Workspace') ->assertDontSee('proof.required_reports_current') ->assertDontSee('proof.required_reports_supersede_operation') ->assertDontSee('complete_required_reports') ->assertDontSee('generate_review_pack') ->assertDontSee('OperationRun') ->assertDontSee('proof_currentness'); }); it('shows technical operation disclosure only for a current same-scope safe-summary proof', function (): void { [$owner, $tenant, $case] = spec388PermissionPostureRequiredReportCase(); [$operator] = createUserWithTenant(tenant: $tenant, user: User::factory()->create(), role: 'operator', workspaceRole: 'operator'); $connection = ProviderConnection::query() ->where('managed_environment_id', (int) $tenant->getKey()) ->where('provider', 'microsoft') ->where('is_default', true) ->firstOrFail(); $run = OperationRun::factory()->forTenant($tenant)->create([ 'type' => 'provider.connection.check', 'status' => OperationRunStatus::Running->value, 'outcome' => OperationRunOutcome::Pending->value, 'started_at' => now(), 'context' => [ 'workspace_id' => (int) $tenant->workspace_id, 'provider_connection_id' => (int) $connection->getKey(), 'trigger' => 'review_publication_resolution', 'environment_review_id' => (int) $case->environment_review_id, 'review_publication_resolution_case_id' => (int) $case->getKey(), ], ]); spec388ForceRunningOperationDisclosureStep($case, $run); $stepState = spec388OperationDisclosureStepState($case, $tenant, $operator); expect($owner)->toBeInstanceOf(User::class) ->and($operator->can('view', $run))->toBeTrue() ->and($stepState['operation_run_id'])->toBe((int) $run->getKey()) ->and($stepState['operation_url'])->not->toBeNull(); }); it('hides technical operation disclosure for a cross-scope operation even when metadata claims currentness', function (): void { [$owner, $tenant, $case] = spec388PermissionPostureRequiredReportCase(); [$operator] = createUserWithTenant(tenant: $tenant, user: User::factory()->create(), role: 'operator', workspaceRole: 'operator'); $otherTenant = ManagedEnvironment::factory()->create(['name' => 'Spec388 Disclosure Other Scope']); $run = OperationRun::factory()->forTenant($otherTenant)->create([ 'type' => 'provider.connection.check', 'status' => OperationRunStatus::Running->value, 'outcome' => OperationRunOutcome::Pending->value, 'started_at' => now(), 'context' => [ 'trigger' => 'review_publication_resolution', 'environment_review_id' => (int) $case->environment_review_id, 'review_publication_resolution_case_id' => (int) $case->getKey(), ], ]); spec388ForceRunningOperationDisclosureStep($case, $run); $stepState = spec388OperationDisclosureStepState($case, $tenant, $operator); expect($owner)->toBeInstanceOf(User::class) ->and($stepState['operation_run_id'])->toBe((int) $run->getKey()) ->and($stepState['operation_url'])->toBeNull(); }); it('hides technical operation disclosure when persisted proof summary is not already safe', function (): void { [$owner, $tenant, $case] = spec388PermissionPostureRequiredReportCase(); [$operator] = createUserWithTenant(tenant: $tenant, user: User::factory()->create(), role: 'operator', workspaceRole: 'operator'); $connection = ProviderConnection::query() ->where('managed_environment_id', (int) $tenant->getKey()) ->where('provider', 'microsoft') ->where('is_default', true) ->firstOrFail(); $run = OperationRun::factory()->forTenant($tenant)->create([ 'type' => 'provider.connection.check', 'status' => OperationRunStatus::Running->value, 'outcome' => OperationRunOutcome::Pending->value, 'started_at' => now(), 'context' => [ 'workspace_id' => (int) $tenant->workspace_id, 'provider_connection_id' => (int) $connection->getKey(), 'trigger' => 'review_publication_resolution', 'environment_review_id' => (int) $case->environment_review_id, 'review_publication_resolution_case_id' => (int) $case->getKey(), ], ]); spec388ForceRunningOperationDisclosureStep($case, $run, [ 'proof_summary' => [ 'label' => 'Operation running', 'operation_type' => 'provider.connection.check', 'raw_graph_response' => [ 'access_token' => 'secret-token', ], ], ]); $stepState = spec388OperationDisclosureStepState($case, $tenant, $operator); expect($owner)->toBeInstanceOf(User::class) ->and($operator->can('view', $run))->toBeTrue() ->and($stepState['operation_run_id'])->toBe((int) $run->getKey()) ->and($stepState['operation_url'])->toBeNull(); }); /** * @return array{0: User, 1: ManagedEnvironment, 2: ReviewPublicationResolutionCase} */ function spec388RequiredReportCaseWithFailedOperation(): array { $tenant = ManagedEnvironment::factory()->create(['name' => 'Spec388 Required Report']); [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); $snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); $permissionReport = StoredReport::query() ->where('managed_environment_id', (int) $tenant->getKey()) ->where('report_type', StoredReport::REPORT_TYPE_PERMISSION_POSTURE) ->latest('id') ->firstOrFail(); $snapshot->items()->where('dimension_key', 'permission_posture')->update([ 'state' => EvidenceCompletenessState::Complete->value, 'source_record_id' => (int) $permissionReport->getKey(), 'source_fingerprint' => (string) $permissionReport->fingerprint, ]); $snapshot->items()->where('dimension_key', 'entra_admin_roles')->update([ 'state' => EvidenceCompletenessState::Missing->value, 'source_record_id' => null, 'source_fingerprint' => null, ]); StoredReport::query() ->where('managed_environment_id', (int) $tenant->getKey()) ->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES) ->delete(); $review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot->fresh('items')); $case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner); $step = $case?->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value); $run = OperationRun::factory()->forTenant($tenant)->create([ 'type' => OperationRunType::EntraAdminRolesScan->value, 'status' => OperationRunStatus::Completed->value, 'outcome' => OperationRunOutcome::Failed->value, 'completed_at' => now()->subMinute(), 'context' => [ 'environment_review_id' => (int) $review->getKey(), 'review_publication_resolution_case_id' => (int) $case?->getKey(), ], ]); $step?->forceFill([ 'status' => ReviewPublicationResolutionStepStatus::Failed->value, 'operation_run_id' => (int) $run->getKey(), 'proof_type' => 'operation_run', 'proof_id' => (int) $run->getKey(), 'proof_status' => OperationRunOutcome::Failed->value, 'metadata' => array_replace(is_array($step?->metadata) ? $step->metadata : [], [ 'readiness_fingerprint' => (string) $case?->readiness_fingerprint, ]), ])->save(); $case?->forceFill([ 'status' => ReviewPublicationResolutionCaseStatus::Blocked->value, 'current_step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value, ])->save(); return [$owner, $tenant, $case->fresh(['steps.operationRun', 'environmentReview.evidenceSnapshot.items'])]; } /** * @return array{0: User, 1: ManagedEnvironment, 2: ReviewPublicationResolutionCase} */ function spec388PermissionPostureRequiredReportCase(): array { $tenant = ManagedEnvironment::factory()->create(['name' => 'Spec388 Permission Posture Report']); [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', fixtureProfile: 'credential-enabled'); $snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); $adminRolesReport = StoredReport::query() ->where('managed_environment_id', (int) $tenant->getKey()) ->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES) ->latest('id') ->firstOrFail(); $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::Complete->value, 'source_record_id' => (int) $adminRolesReport->getKey(), 'source_fingerprint' => (string) $adminRolesReport->fingerprint, ]); $snapshot->items()->where('dimension_key', 'permission_posture')->update([ 'state' => EvidenceCompletenessState::Missing->value, 'source_record_id' => null, 'source_fingerprint' => null, ]); StoredReport::query() ->where('managed_environment_id', (int) $tenant->getKey()) ->where('report_type', StoredReport::REPORT_TYPE_PERMISSION_POSTURE) ->delete(); $review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot->fresh('items')); $case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner); return [$owner, $tenant, $case->fresh(['steps.operationRun', 'environmentReview.evidenceSnapshot.items'])]; } /** * @param array $metadata */ function spec388ForceRunningOperationDisclosureStep( ReviewPublicationResolutionCase $case, OperationRun $run, array $metadata = [], ): void { $step = $case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value); $step?->forceFill([ 'status' => ReviewPublicationResolutionStepStatus::Running->value, 'operation_run_id' => (int) $run->getKey(), 'proof_type' => 'operation_run', 'proof_id' => (int) $run->getKey(), 'proof_status' => (string) $run->status, 'metadata' => array_replace([ 'proof_currentness' => ResolutionProofCurrentness::Current->value, 'proof_usability' => ResolutionProofUsability::InspectionOnly->value, 'proof_visibility' => ResolutionProofVisibility::OperatorVisible->value, 'proof_reason_code' => 'proof.operation_running', 'proof_summary' => [ 'label' => 'Operation running', 'operation_type' => (string) $run->type, ], ], $metadata), ])->save(); $case->forceFill([ 'status' => ReviewPublicationResolutionCaseStatus::WaitingForRun->value, 'current_step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value, ])->save(); } /** * @return array */ function spec388OperationDisclosureStepState( ReviewPublicationResolutionCase $case, ManagedEnvironment $tenant, User $viewer, ): array { setAdminEnvironmentContext($tenant); $component = Livewire::actingAs($viewer) ->test(ResolveReviewPublication::class, ['record' => (int) $case->environment_review_id]); return collect($component->instance()->caseState()['steps'] ?? []) ->firstWhere('key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value) ?? []; } function spec388StoredReport(ManagedEnvironment $tenant, string $reportType): StoredReport { $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) $tenant->workspace_id, 'managed_environment_id' => (int) $tenant->getKey(), 'report_type' => $reportType, 'status' => StoredReport::STATUS_READY, 'generated_at' => now()->addMinute(), 'created_at' => now()->addMinute(), 'updated_at' => now()->addMinute(), ]); } function spec388ReadyReviewWithPack(ManagedEnvironment $tenant, User $owner): EnvironmentReview { $snapshot = seedEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); $permissionReport = spec388StoredReport($tenant, StoredReport::REPORT_TYPE_PERMISSION_POSTURE); $adminRolesReport = spec388StoredReport($tenant, StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES); $snapshot = restateEnvironmentReviewEvidenceSnapshot($snapshot, EvidenceCompletenessState::Complete); $snapshot->items()->where('dimension_key', 'permission_posture')->update([ 'state' => EvidenceCompletenessState::Complete->value, 'source_record_id' => (int) $permissionReport->getKey(), 'source_fingerprint' => (string) $permissionReport->fingerprint, ]); $snapshot->items()->where('dimension_key', 'entra_admin_roles')->update([ 'state' => EvidenceCompletenessState::Complete->value, 'source_record_id' => (int) $adminRolesReport->getKey(), 'source_fingerprint' => (string) $adminRolesReport->fingerprint, ]); $review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot->fresh('items')); $review = markEnvironmentReviewCustomerSafeReady($review); spec388ClearReviewPublicationBlockers($review); Storage::disk('exports')->put('review-packs/spec388-ready.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(), 'status' => ReviewPackStatus::Ready->value, 'fingerprint' => app(ReviewPackService::class)->computeFingerprintForReview($review, $packOptions), 'options' => $packOptions, 'file_path' => 'review-packs/spec388-ready.zip', 'file_disk' => 'exports', ]); $review->forceFill([ 'status' => EnvironmentReviewStatus::Ready->value, 'current_export_review_pack_id' => (int) $pack->getKey(), ])->save(); return $review->fresh(['sections', 'evidenceSnapshot.items', 'currentExportReviewPack']); } function spec388ClearReviewPublicationBlockers(EnvironmentReview $review): void { $baselineReadiness = [ 'state' => EvidenceCompletenessState::Complete->value, 'readiness_state' => 'customer_ready', 'publication_blockers' => [], 'limitations' => [], 'limitation_codes' => [], 'customer_safe_summary' => [ 'readiness_state' => 'customer_ready', 'verified_subject_count' => 1, 'drift_subject_count' => 0, 'blocker_count' => 0, 'limitation_count' => 0, ], ]; $review->sections()->get()->each(function ($section) use ($baselineReadiness): void { $summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : []; $summaryPayload['publication_blockers'] = []; $summaryPayload['baseline_readiness'] = $baselineReadiness; $section->forceFill([ 'summary_payload' => $summaryPayload, ])->save(); }); }