forceFill([ 'status' => 'published', 'published_at' => now(), 'published_by_user_id' => (int) $user->getKey(), ])->save(); $review = markEnvironmentReviewCustomerSafeReady($review); if (! $customerSafeReady) { restateEnvironmentReviewEvidenceSnapshot($review->evidenceSnapshot, EvidenceCompletenessState::Partial); $review = $review->fresh(['sections', 'evidenceSnapshot']); } $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) $user->getKey(), 'options' => [ 'include_pii' => false, 'include_operations' => true, ], 'file_disk' => 'exports', 'file_path' => 'review-packs/spec392-gate.zip', 'expires_at' => now()->addDay(), ]); $review->forceFill([ 'current_export_review_pack_id' => (int) $pack->getKey(), ])->save(); return [$user, $tenant, $review->fresh(['sections', 'evidenceSnapshot', 'currentExportReviewPack']), $pack->fresh(['tenant', 'environmentReview'])]; } it('allows customer output only when the current review pack is customer safe and the actor can view review packs', function (): void { [, $tenant, , $pack] = spec392GateCurrentReviewPack(customerSafeReady: true); [$viewer] = createUserWithTenant( tenant: $tenant, user: \App\Models\User::factory()->create(), role: 'readonly', clearCapabilityCaches: true, ); $decision = app(CustomerOutputGate::class)->decisionForReviewPack($pack, $viewer); expect($decision->canStreamCustomerOutput)->toBeTrue() ->and($decision->canStreamInternalPreview)->toBeFalse() ->and($decision->state)->toBe(CustomerOutputGate::STATE_READY); }); it('denies customer output when no actor is provided even if the output is ready', function (): void { [, , , $pack] = spec392GateCurrentReviewPack(customerSafeReady: true); $decision = app(CustomerOutputGate::class)->decisionForReviewPack($pack, null); expect($decision->canStreamCustomerOutput)->toBeFalse() ->and($decision->canStreamInternalPreview)->toBeFalse() ->and($decision->state)->toBe(CustomerOutputGate::STATE_READY); }); it('denies customer output when the actor is missing review pack view capability even if the output is ready', function (): void { [, , , $pack] = spec392GateCurrentReviewPack(customerSafeReady: true); $unauthorizedActor = \App\Models\User::factory()->create(); $decision = app(CustomerOutputGate::class)->decisionForReviewPack($pack, $unauthorizedActor); expect($decision->canStreamCustomerOutput)->toBeFalse() ->and($decision->canStreamInternalPreview)->toBeFalse() ->and($decision->state)->toBe(CustomerOutputGate::STATE_READY); }); it('denies customer output when the actor can view review packs but the output is not ready', function (): void { [, $tenant, , $pack] = spec392GateCurrentReviewPack(customerSafeReady: false); [$viewer] = createUserWithTenant( tenant: $tenant, user: \App\Models\User::factory()->create(), role: 'readonly', clearCapabilityCaches: true, ); $decision = app(CustomerOutputGate::class)->decisionForReviewPack($pack, $viewer); expect($decision->canStreamCustomerOutput)->toBeFalse() ->and($decision->canStreamInternalPreview)->toBeFalse() ->and($decision->state)->toBe(CustomerOutputGate::STATE_NEEDS_ATTENTION); }); it('keeps unsafe review packs behind internal preview authorization', function (): void { [$owner, $tenant, , $pack] = spec392GateCurrentReviewPack(customerSafeReady: false); [$readonly] = createUserWithTenant( tenant: $tenant, user: \App\Models\User::factory()->create(), role: 'readonly', clearCapabilityCaches: true, ); $ownerDecision = app(CustomerOutputGate::class)->decisionForReviewPack($pack, $owner); $readonlyDecision = app(CustomerOutputGate::class)->decisionForReviewPack($pack, $readonly); expect($ownerDecision->canStreamCustomerOutput)->toBeFalse() ->and($ownerDecision->canStreamInternalPreview)->toBeTrue() ->and($readonlyDecision->canStreamCustomerOutput)->toBeFalse() ->and($readonlyDecision->canStreamInternalPreview)->toBeFalse(); }); it('classifies pii-bearing output as internal preview only', function (): void { [$owner, , , $pack] = spec392GateCurrentReviewPack(customerSafeReady: true); $pack->forceFill([ 'options' => [ 'include_pii' => true, 'include_operations' => true, ], ])->save(); $decision = app(CustomerOutputGate::class)->decisionForReviewPack($pack->fresh(['tenant', 'environmentReview']), $owner); expect($decision->canStreamCustomerOutput)->toBeFalse() ->and($decision->canStreamInternalPreview)->toBeTrue() ->and($decision->state)->toBe(CustomerOutputGate::STATE_INTERNAL_ONLY); }); it('blocks missing and expired artifacts before customer output streaming', function (): void { [$owner, , , $pack] = spec392GateCurrentReviewPack(customerSafeReady: true); $missingArtifact = $pack->replicate()->forceFill([ 'fingerprint' => fake()->sha256(), 'file_path' => null, 'file_disk' => null, ]); $missingArtifact->save(); $expiredArtifact = $pack->replicate()->forceFill([ 'fingerprint' => fake()->sha256(), 'expires_at' => now()->subMinute(), ]); $expiredArtifact->save(); $missingDecision = app(CustomerOutputGate::class)->decisionForReviewPack($missingArtifact->fresh(['tenant', 'environmentReview']), $owner); $expiredDecision = app(CustomerOutputGate::class)->decisionForReviewPack($expiredArtifact->fresh(['tenant', 'environmentReview']), $owner); expect($missingDecision->canStreamCustomerOutput)->toBeFalse() ->and($missingDecision->canStreamInternalPreview)->toBeFalse() ->and($missingDecision->state)->toBe(CustomerOutputGate::STATE_NOT_AVAILABLE) ->and($expiredDecision->canStreamCustomerOutput)->toBeFalse() ->and($expiredDecision->canStreamInternalPreview)->toBeFalse() ->and($expiredDecision->state)->toBe(CustomerOutputGate::STATE_EXPIRED); }); it('does not treat unanchored review pack artifacts as customer output', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner', clearCapabilityCaches: true); $pack = ReviewPack::factory()->ready()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'initiated_by_user_id' => (int) $user->getKey(), 'file_disk' => 'exports', 'file_path' => 'review-packs/spec392-unanchored.zip', 'expires_at' => now()->addDay(), ]); $decision = app(CustomerOutputGate::class)->decisionForReviewPack($pack, $user); expect($decision->canStreamCustomerOutput)->toBeFalse() ->and($decision->canStreamInternalPreview)->toBeTrue() ->and($decision->state)->toBe(CustomerOutputGate::STATE_UNKNOWN); });