TenantAtlas/apps/platform/tests/Unit/Support/ReviewPacks/Spec392CustomerOutputGateTest.php
Ahmed Darrazi 59b45becc1
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m35s
feat: enforce Spec392 customer output gating
2026-06-20 22:52:43 +02:00

182 lines
7.7 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\ReviewPack;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\ReviewPacks\CustomerOutputGate;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function spec392GateCurrentReviewPack(bool $customerSafeReady = true): array
{
[$user, $tenant] = createUserWithTenant(role: 'owner', clearCapabilityCaches: true);
$snapshot = seedEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
$review = composeEnvironmentReviewForTest($tenant, $user, $snapshot);
$review->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);
});