TenantAtlas/apps/platform/tests/Feature/ReviewPack/ReviewPackRbacTest.php
ahmido dd7139ebe3 Spec392 customer output gating (#463)
Implements Spec392 customer output gating for review pack downloads, rendered reports, management PDFs, and customer workspace CTAs.

Validation:
- php vendor/bin/pest --filter=Spec392: 12 passed / 58 assertions
- php vendor/bin/pest --filter='ReviewPack|CustomerReviewWorkspace|StoredReport': 283 passed / 1 skipped / 2053 assertions
- affected browser matrix: 12 passed / 420 assertions
- php vendor/bin/pint --dirty: pass
- git diff --check: pass

Notes:
- Deprecated limited-download semantics remain removed.
- Unsafe customer-facing output returns 403/no output.
- Internal preview/report access is operator-only.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #463
2026-06-20 20:54:50 +00:00

263 lines
9.4 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Resources\ReviewPackResource;
use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks;
use App\Models\ManagedEnvironment;
use App\Models\ReviewPack;
use App\Services\ReviewPackService;
use App\Support\Auth\UiTooltips;
use App\Support\ReviewPackStatus;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Storage::fake('exports');
});
function getReviewPackRbacEmptyStateAction(Testable $component, string $name): ?Action
{
foreach ($component->instance()->getTable()->getEmptyStateActions() as $action) {
if ($action instanceof Action && $action->getName() === $name) {
return $action;
}
}
return null;
}
function createCustomerSafeReviewPackForRbac(ManagedEnvironment $tenant, \App\Models\User $user, string $filePath): ReviewPack
{
$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);
$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) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'options' => [
'include_pii' => false,
'include_operations' => true,
],
'file_path' => $filePath,
'file_disk' => 'exports',
'expires_at' => now()->addDay(),
]);
$review->forceFill([
'current_export_review_pack_id' => (int) $pack->getKey(),
])->save();
return $pack->fresh(['tenant', 'environmentReview']);
}
// ─── Non-Member Access ───────────────────────────────────────
it('returns 404 for non-member on list page', function (): void {
$targetTenant = ManagedEnvironment::factory()->create();
$otherTenant = ManagedEnvironment::factory()->create();
[$user] = createUserWithTenant($otherTenant, role: 'owner');
$this->actingAs($user)
->get(ReviewPackResource::getUrl('index', tenant: $targetTenant, panel: 'admin'))
->assertNotFound();
});
it('returns 404 for non-member on view page', function (): void {
$targetTenant = ManagedEnvironment::factory()->create();
$otherTenant = ManagedEnvironment::factory()->create();
[$user] = createUserWithTenant($otherTenant, role: 'owner');
$pack = ReviewPack::factory()->ready()->create([
'managed_environment_id' => (int) $targetTenant->getKey(),
]);
$this->actingAs($user)
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $targetTenant, panel: 'admin'))
->assertNotFound();
});
it('returns 404 for non-member on download route', function (): void {
$targetTenant = ManagedEnvironment::factory()->create();
$otherTenant = ManagedEnvironment::factory()->create();
[$user] = createUserWithTenant($otherTenant, role: 'owner');
$filePath = 'review-packs/rbac-test.zip';
Storage::disk('exports')->put($filePath, 'PK-fake');
$pack = ReviewPack::factory()->ready()->create([
'managed_environment_id' => (int) $targetTenant->getKey(),
'file_path' => $filePath,
'file_disk' => 'exports',
]);
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack);
$this->actingAs($user)->get($signedUrl)->assertNotFound();
});
// ─── REVIEW_PACK_VIEW Member ────────────────────────────────
it('allows readonly member to access list page', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$this->actingAs($user)
->get(ReviewPackResource::getUrl('index', tenant: $tenant, panel: 'admin'))
->assertOk();
});
it('allows readonly member to access view page', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$pack = ReviewPack::factory()->ready()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
]);
$this->actingAs($user)
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant, panel: 'admin'))
->assertOk();
});
it('allows readonly member to download via signed URL', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$filePath = 'review-packs/readonly-test.zip';
Storage::disk('exports')->put($filePath, 'PK-fake');
$pack = createCustomerSafeReviewPackForRbac($tenant, $user, $filePath);
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack);
$this->actingAs($user)->get($signedUrl)->assertOk();
});
it('disables generate action for readonly member', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::actingAs($user)
->test(ListReviewPacks::class)
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
$emptyStateAction = getReviewPackRbacEmptyStateAction($component, 'generate_first');
expect($emptyStateAction)->not->toBeNull()
->and($emptyStateAction?->isDisabled())->toBeTrue()
->and($emptyStateAction?->getTooltip())->toBe(UiTooltips::insufficientPermission());
});
// ─── REVIEW_PACK_MANAGE Member ──────────────────────────────
it('allows owner to generate a review pack', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
ReviewPack::factory()->ready()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
]);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(ListReviewPacks::class)
->assertActionVisible('generate_pack')
->assertActionEnabled('generate_pack');
});
it('allows owner to expire a ready pack', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$filePath = 'review-packs/expire-rbac.zip';
Storage::disk('exports')->put($filePath, 'PK-fake');
$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_path' => $filePath,
'file_disk' => 'exports',
]);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(ListReviewPacks::class)
->assertTableActionVisible('expire', $pack)
->callTableAction('expire', $pack);
$pack->refresh();
expect($pack->status)->toBe(ReviewPackStatus::Expired->value);
});
it('disables expire action for readonly member', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$filePath = 'review-packs/expire-readonly.zip';
Storage::disk('exports')->put($filePath, 'PK-fake');
$pack = ReviewPack::factory()->ready()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'file_path' => $filePath,
'file_disk' => 'exports',
]);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(ListReviewPacks::class)
->assertTableActionVisible('expire', $pack)
->assertTableActionDisabled('expire', $pack);
});
// ─── Signed URL Security ────────────────────────────────────
it('rejects unsigned download URL with 403', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$pack = ReviewPack::factory()->ready()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]);
$response = $this->actingAs($user)
->get(route('admin.review-packs.download', ['reviewPack' => $pack->getKey()]));
$response->assertForbidden();
});