TenantAtlas/apps/platform/tests/Feature/EnvironmentReview/Spec388ReviewPublicationProofCurrentnessTest.php
Ahmed Darrazi 314a157233
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m18s
feat: add review publication proof currentness contract
2026-06-19 20:59:05 +02:00

1327 lines
70 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\EnvironmentReviewResource\Pages\ResolveReviewPublication;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Models\AuditLog;
use App\Models\EnvironmentReview;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\ReviewPack;
use App\Models\ReviewPublicationResolutionCase;
use App\Models\StoredReport;
use App\Models\User;
use App\Services\OperationRunService;
use App\Services\ReviewPackService;
use App\Support\Audit\AuditActionId;
use App\Support\EnvironmentReviewStatus;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\ReviewPackStatus;
use App\Support\ReviewPublicationResolution\ResolutionProofCurrentness;
use App\Support\ReviewPublicationResolution\ResolutionProofUsability;
use App\Support\ReviewPublicationResolution\ResolutionProofVisibility;
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 Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Storage::fake('exports');
});
it('uses newer current stored report proof to supersede an old failed report operation', function (): void {
[$owner, $tenant, $case] = spec388RequiredReportCaseWithFailedOperation();
$step = $case->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<string, mixed> $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<string, mixed>
*/
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();
});
}