344 lines
13 KiB
PHP
344 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Filament\Resources\ReviewPackResource;
|
|
use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks;
|
|
use App\Models\AuditLog;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\ReviewPack;
|
|
use App\Services\ReviewPackService;
|
|
use App\Support\Audit\AuditActionId;
|
|
use App\Support\Auth\UiTooltips;
|
|
use App\Support\ReviewPackStatus;
|
|
use Filament\Actions\Action;
|
|
use Filament\Facades\Filament;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Auth\Access\AuthorizationException;
|
|
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',
|
|
'file_size' => Storage::disk('exports')->size($filePath),
|
|
'sha256' => hash('sha256', Storage::disk('exports')->get($filePath)),
|
|
'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)
|
|
->assertTableActionExists('expire', fn (Action $action): bool => $action->isConfirmationRequired(), $pack)
|
|
->assertTableActionVisible('expire', $pack)
|
|
->callTableAction('expire', $pack);
|
|
|
|
$pack->refresh();
|
|
$audit = AuditLog::query()
|
|
->where('action', AuditActionId::ReviewPackExpired->value)
|
|
->latest('id')
|
|
->first();
|
|
|
|
expect($pack->status)->toBe(ReviewPackStatus::Expired->value)
|
|
->and(Storage::disk('exports')->exists($filePath))->toBeFalse()
|
|
->and($audit)->not->toBeNull()
|
|
->and($audit?->resource_type)->toBe('review_pack')
|
|
->and(data_get($audit?->metadata, 'review_pack_id'))->toBe((int) $pack->getKey())
|
|
->and(data_get($audit?->metadata, 'artifact_family'))->toBe('review_pack')
|
|
->and(data_get($audit?->metadata, 'before_status'))->toBe(ReviewPackStatus::Ready->value)
|
|
->and(data_get($audit?->metadata, 'after_status'))->toBe(ReviewPackStatus::Expired->value)
|
|
->and(data_get($audit?->metadata, 'file_deleted'))->toBeTrue();
|
|
});
|
|
|
|
it('Spec406 does not delete foreign-disk files when expiring a review pack', function (): void {
|
|
$tenant = ManagedEnvironment::factory()->create();
|
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
|
|
Storage::fake('public');
|
|
|
|
$exportsPath = 'review-packs/exports-same-path.zip';
|
|
$publicPath = 'review-packs/foreign-disk.zip';
|
|
Storage::disk('exports')->put($exportsPath, 'PK-exports');
|
|
Storage::disk('public')->put($publicPath, 'PK-public');
|
|
|
|
$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' => $publicPath,
|
|
'file_disk' => 'public',
|
|
'file_size' => Storage::disk('public')->size($publicPath),
|
|
'sha256' => hash('sha256', Storage::disk('public')->get($publicPath)),
|
|
]);
|
|
|
|
$expiredPack = app(ReviewPackService::class)->expire($pack, $user, 'spec406_wrong_disk');
|
|
|
|
$audit = AuditLog::query()
|
|
->where('action', AuditActionId::ReviewPackExpired->value)
|
|
->latest('id')
|
|
->first();
|
|
|
|
expect($expiredPack->status)->toBe(ReviewPackStatus::Expired->value)
|
|
->and(Storage::disk('public')->exists($publicPath))->toBeTrue()
|
|
->and(Storage::disk('exports')->exists($exportsPath))->toBeTrue()
|
|
->and($audit)->not->toBeNull()
|
|
->and(data_get($audit?->metadata, 'file_disk'))->toBe('public')
|
|
->and(data_get($audit?->metadata, 'file_deleted'))->toBeFalse();
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it('denies direct review pack lifecycle expiration for readonly members', function (): void {
|
|
$tenant = ManagedEnvironment::factory()->create();
|
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
|
|
|
$filePath = 'review-packs/expire-direct-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',
|
|
'file_size' => Storage::disk('exports')->size($filePath),
|
|
'sha256' => hash('sha256', Storage::disk('exports')->get($filePath)),
|
|
]);
|
|
|
|
expect(fn (): ReviewPack => app(ReviewPackService::class)->expire($pack, $user, 'spec406_direct'))
|
|
->toThrow(AuthorizationException::class);
|
|
|
|
$pack->refresh();
|
|
|
|
expect($pack->status)->toBe(ReviewPackStatus::Ready->value)
|
|
->and(Storage::disk('exports')->exists($filePath))->toBeTrue()
|
|
->and(AuditLog::query()->where('action', AuditActionId::ReviewPackExpired->value)->count())->toBe(0);
|
|
});
|
|
|
|
// ─── 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();
|
|
});
|