merge: agent session work — spec 109 complete
This commit is contained in:
commit
ec12c5b7a5
77
app/Console/Commands/PruneReviewPacksCommand.php
Normal file
77
app/Console/Commands/PruneReviewPacksCommand.php
Normal file
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ReviewPack;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PruneReviewPacksCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'tenantpilot:review-pack:prune {--hard-delete : Hard-delete expired packs past grace period}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Expire review packs past retention and optionally hard-delete expired rows past grace period';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$expired = $this->expireReadyPacks();
|
||||
$hardDeleted = 0;
|
||||
|
||||
if ($this->option('hard-delete')) {
|
||||
$hardDeleted = $this->hardDeleteExpiredPacks();
|
||||
}
|
||||
|
||||
$this->info("{$expired} pack(s) expired, {$hardDeleted} pack(s) hard-deleted.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition ready packs past retention to expired and delete their files.
|
||||
*/
|
||||
private function expireReadyPacks(): int
|
||||
{
|
||||
$packs = ReviewPack::query()
|
||||
->ready()
|
||||
->pastRetention()
|
||||
->get();
|
||||
|
||||
$disk = Storage::disk('exports');
|
||||
$count = 0;
|
||||
|
||||
foreach ($packs as $pack) {
|
||||
/** @var ReviewPack $pack */
|
||||
if ($pack->file_path && $disk->exists($pack->file_path)) {
|
||||
$disk->delete($pack->file_path);
|
||||
}
|
||||
|
||||
$pack->update(['status' => ReviewPack::STATUS_EXPIRED]);
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-delete expired packs that are past the grace period.
|
||||
*/
|
||||
private function hardDeleteExpiredPacks(): int
|
||||
{
|
||||
$graceDays = (int) config('tenantpilot.review_pack.hard_delete_grace_days', 30);
|
||||
|
||||
$cutoff = now()->subDays($graceDays);
|
||||
|
||||
return ReviewPack::query()
|
||||
->expired()
|
||||
->where('updated_at', '<', $cutoff)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
@ -378,7 +378,6 @@ public static function eventTypeOptions(): array
|
||||
return [
|
||||
AlertRule::EVENT_HIGH_DRIFT => 'High drift',
|
||||
AlertRule::EVENT_COMPARE_FAILED => 'Compare failed',
|
||||
AlertRule::EVENT_SLA_DUE => 'SLA due',
|
||||
AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing',
|
||||
AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)',
|
||||
];
|
||||
|
||||
336
app/Filament/Resources/ReviewPackResource.php
Normal file
336
app/Filament/Resources/ReviewPackResource.php
Normal file
@ -0,0 +1,336 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\ReviewPackResource\Pages;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Number;
|
||||
use UnitEnum;
|
||||
|
||||
class ReviewPackResource extends Resource
|
||||
{
|
||||
protected static ?string $model = ReviewPack::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
|
||||
protected static bool $isGloballySearchable = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-arrow-down';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||
|
||||
protected static ?string $navigationLabel = 'Review Packs';
|
||||
|
||||
protected static ?int $navigationSort = 50;
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant);
|
||||
}
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($record instanceof ReviewPack) {
|
||||
return (int) $record->tenant_id === (int) $tenant->getKey();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action available in list header.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Only two primary row actions (Download, Expire); no secondary menu needed.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.');
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Status')
|
||||
->schema([
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ReviewPackStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::ReviewPackStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus)),
|
||||
TextEntry::make('tenant.name')->label('Tenant'),
|
||||
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('expires_at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('file_size')
|
||||
->label('File size')
|
||||
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—'),
|
||||
TextEntry::make('sha256')->label('SHA-256')->copyable()->placeholder('—'),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Summary')
|
||||
->schema([
|
||||
TextEntry::make('summary.finding_count')->label('Findings')->placeholder('—'),
|
||||
TextEntry::make('summary.report_count')->label('Reports')->placeholder('—'),
|
||||
TextEntry::make('summary.operation_count')->label('Operations')->placeholder('—'),
|
||||
TextEntry::make('summary.data_freshness.permission_posture')
|
||||
->label('Permission posture freshness')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('summary.data_freshness.entra_admin_roles')
|
||||
->label('Entra admin roles freshness')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('summary.data_freshness.findings')
|
||||
->label('Findings freshness')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('summary.data_freshness.hardening')
|
||||
->label('Hardening freshness')
|
||||
->placeholder('—'),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Options')
|
||||
->schema([
|
||||
TextEntry::make('options.include_pii')
|
||||
->label('Include PII')
|
||||
->formatStateUsing(fn ($state): string => $state ? 'Yes' : 'No'),
|
||||
TextEntry::make('options.include_operations')
|
||||
->label('Include operations')
|
||||
->formatStateUsing(fn ($state): string => $state ? 'Yes' : 'No'),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Metadata')
|
||||
->schema([
|
||||
TextEntry::make('initiator.name')->label('Initiated by')->placeholder('—'),
|
||||
TextEntry::make('operationRun.id')
|
||||
->label('Operation run')
|
||||
->url(fn (ReviewPack $record): ?string => $record->operation_run_id
|
||||
? route('admin.operations.view', ['run' => (int) $record->operation_run_id])
|
||||
: null)
|
||||
->openUrlInNewTab()
|
||||
->placeholder('—'),
|
||||
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—'),
|
||||
TextEntry::make('previous_fingerprint')->label('Previous fingerprint')->copyable()->placeholder('—'),
|
||||
TextEntry::make('created_at')->label('Created')->dateTime(),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('created_at', 'desc')
|
||||
->recordUrl(fn (ReviewPack $record): string => static::getUrl('view', ['record' => $record]))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ReviewPackStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::ReviewPackStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('generated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('expires_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('file_size')
|
||||
->label('Size')
|
||||
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('Created')
|
||||
->since()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('status')
|
||||
->options(collect(ReviewPackStatus::cases())
|
||||
->mapWithKeys(fn (ReviewPackStatus $s): array => [$s->value => ucfirst($s->value)])
|
||||
->all()),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('download')
|
||||
->label('Download')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->color('success')
|
||||
->visible(fn (ReviewPack $record): bool => $record->status === ReviewPackStatus::Ready->value)
|
||||
->url(function (ReviewPack $record): string {
|
||||
return app(ReviewPackService::class)->generateDownloadUrl($record);
|
||||
})
|
||||
->openUrlInNewTab(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('expire')
|
||||
->label('Expire')
|
||||
->icon('heroicon-o-clock')
|
||||
->color('danger')
|
||||
->visible(fn (ReviewPack $record): bool => $record->status === ReviewPackStatus::Ready->value)
|
||||
->requiresConfirmation()
|
||||
->modalDescription('This will mark the pack as expired and delete the file. This cannot be undone.')
|
||||
->action(function (ReviewPack $record): void {
|
||||
if ($record->file_path && $record->file_disk) {
|
||||
\Illuminate\Support\Facades\Storage::disk($record->file_disk)->delete($record->file_path);
|
||||
}
|
||||
|
||||
$record->update(['status' => ReviewPackStatus::Expired->value]);
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Review pack expired')
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
||||
->apply(),
|
||||
])
|
||||
->emptyStateHeading('No review packs yet')
|
||||
->emptyStateDescription('Generate a review pack to export tenant data for external review.')
|
||||
->emptyStateIcon('heroicon-o-document-arrow-down')
|
||||
->emptyStateActions([
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('generate_first')
|
||||
->label('Generate first pack')
|
||||
->icon('heroicon-o-plus')
|
||||
->action(function (array $data): void {
|
||||
static::executeGeneration($data);
|
||||
})
|
||||
->form([
|
||||
Section::make('Pack options')
|
||||
->schema([
|
||||
Toggle::make('include_pii')
|
||||
->label('Include PII')
|
||||
->helperText('Include personally identifiable information in the export.')
|
||||
->default(config('tenantpilot.review_pack.include_pii_default', true)),
|
||||
Toggle::make('include_operations')
|
||||
->label('Include operations')
|
||||
->helperText('Include recent operation history in the export.')
|
||||
->default(config('tenantpilot.review_pack.include_operations_default', true)),
|
||||
]),
|
||||
])
|
||||
)
|
||||
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
||||
->apply(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey());
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListReviewPacks::route('/'),
|
||||
'view' => Pages\ViewReviewPack::route('/{record}'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function executeGeneration(array $data): void
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
Notification::make()->danger()->title('Unable to generate pack — missing context.')->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(ReviewPackService::class);
|
||||
|
||||
if ($service->checkActiveRun($tenant)) {
|
||||
Notification::make()->warning()->title('A review pack is already being generated.')->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$options = [
|
||||
'include_pii' => (bool) ($data['include_pii'] ?? true),
|
||||
'include_operations' => (bool) ($data['include_operations'] ?? true),
|
||||
];
|
||||
|
||||
$service->generate($tenant, $user, $options);
|
||||
|
||||
Notification::make()->success()->title('Review pack generation started.')->send();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ReviewPackResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Schemas\Components\Section;
|
||||
|
||||
class ListReviewPacks extends ListRecords
|
||||
{
|
||||
protected static string $resource = ReviewPackResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('generate_pack')
|
||||
->label('Generate Pack')
|
||||
->icon('heroicon-o-plus')
|
||||
->action(function (array $data): void {
|
||||
ReviewPackResource::executeGeneration($data);
|
||||
})
|
||||
->form([
|
||||
Section::make('Pack options')
|
||||
->schema([
|
||||
Toggle::make('include_pii')
|
||||
->label('Include PII')
|
||||
->helperText('Include personally identifiable information in the export.')
|
||||
->default(config('tenantpilot.review_pack.include_pii_default', true)),
|
||||
Toggle::make('include_operations')
|
||||
->label('Include operations')
|
||||
->helperText('Include recent operation history in the export.')
|
||||
->default(config('tenantpilot.review_pack.include_operations_default', true)),
|
||||
]),
|
||||
])
|
||||
)
|
||||
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
||||
->apply(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ReviewPackResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Filament\Schemas\Components\Section;
|
||||
|
||||
class ViewReviewPack extends ViewRecord
|
||||
{
|
||||
protected static string $resource = ReviewPackResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('download')
|
||||
->label('Download')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->color('success')
|
||||
->visible(fn (): bool => $this->record->status === ReviewPackStatus::Ready->value)
|
||||
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record))
|
||||
->openUrlInNewTab(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('regenerate')
|
||||
->label('Regenerate')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('This will generate a new review pack with the same options. The current pack will remain available until it expires.')
|
||||
->action(function (array $data): void {
|
||||
/** @var ReviewPack $record */
|
||||
$record = $this->record;
|
||||
|
||||
$options = array_merge($record->options ?? [], [
|
||||
'include_pii' => (bool) ($data['include_pii'] ?? ($record->options['include_pii'] ?? true)),
|
||||
'include_operations' => (bool) ($data['include_operations'] ?? ($record->options['include_operations'] ?? true)),
|
||||
]);
|
||||
|
||||
ReviewPackResource::executeGeneration($options);
|
||||
})
|
||||
->form(function (): array {
|
||||
/** @var ReviewPack $record */
|
||||
$record = $this->record;
|
||||
$currentOptions = $record->options ?? [];
|
||||
|
||||
return [
|
||||
Section::make('Pack options')
|
||||
->schema([
|
||||
Toggle::make('include_pii')
|
||||
->label('Include PII')
|
||||
->helperText('Include personally identifiable information in the export.')
|
||||
->default((bool) ($currentOptions['include_pii'] ?? true)),
|
||||
Toggle::make('include_operations')
|
||||
->label('Include operations')
|
||||
->helperText('Include recent operation history in the export.')
|
||||
->default((bool) ($currentOptions['include_operations'] ?? true)),
|
||||
]),
|
||||
];
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
||||
->apply(),
|
||||
];
|
||||
}
|
||||
}
|
||||
158
app/Filament/Widgets/Tenant/TenantReviewPackCard.php
Normal file
158
app/Filament/Widgets/Tenant/TenantReviewPackCard.php
Normal file
@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets\Tenant;
|
||||
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class TenantReviewPackCard extends Widget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected string $view = 'filament.widgets.tenant.tenant-review-pack-card';
|
||||
|
||||
public ?Tenant $record = null;
|
||||
|
||||
private function resolveTenant(): ?Tenant
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
return $this->record instanceof Tenant ? $this->record : null;
|
||||
}
|
||||
|
||||
public function generatePack(bool $includePii = true, bool $includeOperations = true): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = $this->resolveTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
|
||||
if ($service->checkActiveRun($tenant)) {
|
||||
Notification::make()
|
||||
->title('Generation already in progress')
|
||||
->body('A review pack is currently being generated for this tenant.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$service->generate($tenant, $user, [
|
||||
'include_pii' => $includePii,
|
||||
'include_operations' => $includeOperations,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title('Review pack generation started')
|
||||
->body('The pack will be generated in the background. You will be notified when it is ready.')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$tenant = $this->resolveTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return $this->emptyState();
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$isTenantMember = $user instanceof User && $user->canAccessTenant($tenant);
|
||||
$canView = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant);
|
||||
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
|
||||
|
||||
$latestPack = ReviewPack::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if (! $latestPack instanceof ReviewPack) {
|
||||
return [
|
||||
'tenant' => $tenant,
|
||||
'pack' => null,
|
||||
'statusEnum' => null,
|
||||
'canView' => $canView,
|
||||
'canManage' => $canManage,
|
||||
'downloadUrl' => null,
|
||||
'failedReason' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$statusEnum = ReviewPackStatus::tryFrom((string) $latestPack->status);
|
||||
|
||||
$downloadUrl = null;
|
||||
if ($statusEnum === ReviewPackStatus::Ready && $canView) {
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
$downloadUrl = $service->generateDownloadUrl($latestPack);
|
||||
}
|
||||
|
||||
$failedReason = null;
|
||||
if ($statusEnum === ReviewPackStatus::Failed && $latestPack->operationRun) {
|
||||
$opContext = is_array($latestPack->operationRun->context) ? $latestPack->operationRun->context : [];
|
||||
$failedReason = (string) ($opContext['reason_code'] ?? 'Unknown error');
|
||||
}
|
||||
|
||||
return [
|
||||
'tenant' => $tenant,
|
||||
'pack' => $latestPack,
|
||||
'statusEnum' => $statusEnum,
|
||||
'canView' => $canView,
|
||||
'canManage' => $canManage,
|
||||
'downloadUrl' => $downloadUrl,
|
||||
'failedReason' => $failedReason,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function emptyState(): array
|
||||
{
|
||||
return [
|
||||
'tenant' => null,
|
||||
'pack' => null,
|
||||
'statusEnum' => null,
|
||||
'canView' => false,
|
||||
'canManage' => false,
|
||||
'downloadUrl' => null,
|
||||
'failedReason' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
43
app/Http/Controllers/ReviewPackDownloadController.php
Normal file
43
app/Http/Controllers/ReviewPackDownloadController.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ReviewPack;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class ReviewPackDownloadController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResponse
|
||||
{
|
||||
if ($reviewPack->status !== ReviewPackStatus::Ready->value) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if ($reviewPack->expires_at && $reviewPack->expires_at->isPast()) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
$disk = Storage::disk($reviewPack->file_disk ?? 'exports');
|
||||
|
||||
if (! $disk->exists($reviewPack->file_path)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
$tenant = $reviewPack->tenant;
|
||||
$filename = sprintf(
|
||||
'review-pack-%s-%s.zip',
|
||||
$tenant?->external_id ?? 'unknown',
|
||||
$reviewPack->generated_at?->format('Y-m-d') ?? now()->format('Y-m-d'),
|
||||
);
|
||||
|
||||
return $disk->download($reviewPack->file_path, $filename, [
|
||||
'X-Review-Pack-SHA256' => $reviewPack->sha256 ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
419
app/Jobs/GenerateReviewPackJob.php
Normal file
419
app/Jobs/GenerateReviewPackJob.php
Normal file
@ -0,0 +1,419 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Notifications\ReviewPackStatusNotification;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Throwable;
|
||||
use ZipArchive;
|
||||
|
||||
class GenerateReviewPackJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public int $reviewPackId,
|
||||
public int $operationRunId,
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$reviewPack = ReviewPack::query()->find($this->reviewPackId);
|
||||
$operationRun = OperationRun::query()->find($this->operationRunId);
|
||||
|
||||
if (! $reviewPack instanceof ReviewPack || ! $operationRun instanceof OperationRun) {
|
||||
Log::warning('GenerateReviewPackJob: missing records', [
|
||||
'review_pack_id' => $this->reviewPackId,
|
||||
'operation_run_id' => $this->operationRunId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $reviewPack->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$this->markFailed($reviewPack, $operationRun, 'tenant_not_found', 'Tenant not found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark running
|
||||
$operationRun->update([
|
||||
'status' => OperationRunStatus::Running->value,
|
||||
'started_at' => now(),
|
||||
]);
|
||||
$reviewPack->update(['status' => ReviewPackStatus::Generating->value]);
|
||||
|
||||
try {
|
||||
$this->executeGeneration($reviewPack, $operationRun, $tenant);
|
||||
} catch (Throwable $e) {
|
||||
$this->markFailed($reviewPack, $operationRun, 'generation_error', $e->getMessage());
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function executeGeneration(ReviewPack $reviewPack, OperationRun $operationRun, Tenant $tenant): void
|
||||
{
|
||||
$options = $reviewPack->options ?? [];
|
||||
$includePii = (bool) ($options['include_pii'] ?? true);
|
||||
$includeOperations = (bool) ($options['include_operations'] ?? true);
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
// 1. Collect StoredReports
|
||||
$storedReports = StoredReport::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('report_type', [
|
||||
StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||
])
|
||||
->get()
|
||||
->keyBy('report_type');
|
||||
|
||||
// 2. Collect Findings (open + acknowledged)
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_ACKNOWLEDGED])
|
||||
->orderBy('severity')
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
|
||||
// 3. Collect tenant hardening fields
|
||||
$hardening = [
|
||||
'rbac_last_checked_at' => $tenant->rbac_last_checked_at?->toIso8601String(),
|
||||
'rbac_last_setup_at' => $tenant->rbac_last_setup_at?->toIso8601String(),
|
||||
'rbac_canary_results' => $tenant->rbac_canary_results,
|
||||
'rbac_last_warnings' => $tenant->rbac_last_warnings,
|
||||
'rbac_scope_mode' => $tenant->rbac_scope_mode,
|
||||
];
|
||||
|
||||
// 4. Collect recent OperationRuns (30 days)
|
||||
$recentOperations = $includeOperations
|
||||
? OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('created_at', '>=', now()->subDays(30))
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
: collect();
|
||||
|
||||
// 5. Data freshness
|
||||
$dataFreshness = $this->computeDataFreshness($storedReports, $findings, $tenant);
|
||||
|
||||
// 6. Build file map
|
||||
$fileMap = $this->buildFileMap(
|
||||
storedReports: $storedReports,
|
||||
findings: $findings,
|
||||
hardening: $hardening,
|
||||
recentOperations: $recentOperations,
|
||||
tenant: $tenant,
|
||||
dataFreshness: $dataFreshness,
|
||||
includePii: $includePii,
|
||||
includeOperations: $includeOperations,
|
||||
);
|
||||
|
||||
// 7. Assemble ZIP
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'review-pack-');
|
||||
|
||||
try {
|
||||
$this->assembleZip($tempFile, $fileMap);
|
||||
|
||||
// 8. Compute SHA-256
|
||||
$sha256 = hash_file('sha256', $tempFile);
|
||||
$fileSize = filesize($tempFile);
|
||||
|
||||
// 9. Store on exports disk
|
||||
$filePath = sprintf(
|
||||
'review-packs/%s/%s.zip',
|
||||
$tenant->external_id,
|
||||
now()->format('Y-m-d-His'),
|
||||
);
|
||||
|
||||
Storage::disk('exports')->put($filePath, file_get_contents($tempFile));
|
||||
} finally {
|
||||
if (file_exists($tempFile)) {
|
||||
unlink($tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Compute fingerprint
|
||||
$fingerprint = app(ReviewPackService::class)->computeFingerprint($tenant, $options);
|
||||
|
||||
// 11. Compute summary
|
||||
$summary = [
|
||||
'finding_count' => $findings->count(),
|
||||
'report_count' => $storedReports->count(),
|
||||
'operation_count' => $recentOperations->count(),
|
||||
'data_freshness' => $dataFreshness,
|
||||
];
|
||||
|
||||
// 12. Update ReviewPack
|
||||
$retentionDays = (int) config('tenantpilot.review_pack.retention_days', 90);
|
||||
$reviewPack->update([
|
||||
'status' => ReviewPackStatus::Ready->value,
|
||||
'fingerprint' => $fingerprint,
|
||||
'sha256' => $sha256,
|
||||
'file_size' => $fileSize,
|
||||
'file_path' => $filePath,
|
||||
'file_disk' => 'exports',
|
||||
'generated_at' => now(),
|
||||
'expires_at' => now()->addDays($retentionDays),
|
||||
'summary' => $summary,
|
||||
]);
|
||||
|
||||
// 13. Mark OperationRun completed
|
||||
$operationRun->update([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now(),
|
||||
'summary_counts' => $summary,
|
||||
]);
|
||||
|
||||
// 14. Notify initiator
|
||||
$this->notifyInitiator($reviewPack, 'ready');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Illuminate\Support\Collection<string, StoredReport> $storedReports
|
||||
* @param \Illuminate\Database\Eloquent\Collection<int, Finding> $findings
|
||||
* @return array<string, ?string>
|
||||
*/
|
||||
private function computeDataFreshness($storedReports, $findings, Tenant $tenant): array
|
||||
{
|
||||
return [
|
||||
'permission_posture' => $storedReports->get(StoredReport::REPORT_TYPE_PERMISSION_POSTURE)?->updated_at?->toIso8601String(),
|
||||
'entra_admin_roles' => $storedReports->get(StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)?->updated_at?->toIso8601String(),
|
||||
'findings' => $findings->max('updated_at')?->toIso8601String() ?? $findings->max('created_at')?->toIso8601String(),
|
||||
'hardening' => $tenant->rbac_last_checked_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the file map for the ZIP contents.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildFileMap(
|
||||
$storedReports,
|
||||
$findings,
|
||||
array $hardening,
|
||||
$recentOperations,
|
||||
Tenant $tenant,
|
||||
array $dataFreshness,
|
||||
bool $includePii,
|
||||
bool $includeOperations,
|
||||
): array {
|
||||
$files = [];
|
||||
|
||||
// findings.csv
|
||||
$files['findings.csv'] = $this->buildFindingsCsv($findings, $includePii);
|
||||
|
||||
// hardening.json
|
||||
$files['hardening.json'] = json_encode($hardening, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
||||
|
||||
// metadata.json
|
||||
$files['metadata.json'] = json_encode([
|
||||
'version' => '1.0',
|
||||
'tenant_id' => $tenant->external_id,
|
||||
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'options' => [
|
||||
'include_pii' => $includePii,
|
||||
'include_operations' => $includeOperations,
|
||||
],
|
||||
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
||||
|
||||
// operations.csv
|
||||
$files['operations.csv'] = $this->buildOperationsCsv($recentOperations, $includePii);
|
||||
|
||||
// reports/entra_admin_roles.json
|
||||
$entraReport = $storedReports->get(StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES);
|
||||
$files['reports/entra_admin_roles.json'] = json_encode(
|
||||
$entraReport ? $this->redactReportPayload($entraReport->payload ?? [], $includePii) : [],
|
||||
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
||||
);
|
||||
|
||||
// reports/permission_posture.json
|
||||
$postureReport = $storedReports->get(StoredReport::REPORT_TYPE_PERMISSION_POSTURE);
|
||||
$files['reports/permission_posture.json'] = json_encode(
|
||||
$postureReport ? $this->redactReportPayload($postureReport->payload ?? [], $includePii) : [],
|
||||
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
||||
);
|
||||
|
||||
// summary.json
|
||||
$files['summary.json'] = json_encode([
|
||||
'data_freshness' => $dataFreshness,
|
||||
'finding_count' => $findings->count(),
|
||||
'report_count' => $storedReports->count(),
|
||||
'operation_count' => $recentOperations->count(),
|
||||
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build findings CSV content.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Collection<int, Finding> $findings
|
||||
*/
|
||||
private function buildFindingsCsv($findings, bool $includePii): string
|
||||
{
|
||||
$handle = fopen('php://temp', 'r+');
|
||||
fputcsv($handle, ['id', 'finding_type', 'severity', 'status', 'title', 'description', 'created_at', 'updated_at']);
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
fputcsv($handle, [
|
||||
$finding->id,
|
||||
$finding->finding_type,
|
||||
$finding->severity,
|
||||
$finding->status,
|
||||
$includePii ? ($finding->title ?? '') : '[REDACTED]',
|
||||
$includePii ? ($finding->description ?? '') : '[REDACTED]',
|
||||
$finding->created_at?->toIso8601String(),
|
||||
$finding->updated_at?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
rewind($handle);
|
||||
$content = stream_get_contents($handle);
|
||||
fclose($handle);
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build operations CSV content.
|
||||
*/
|
||||
private function buildOperationsCsv($operations, bool $includePii): string
|
||||
{
|
||||
$handle = fopen('php://temp', 'r+');
|
||||
fputcsv($handle, ['id', 'type', 'status', 'outcome', 'initiator', 'started_at', 'completed_at']);
|
||||
|
||||
foreach ($operations as $operation) {
|
||||
fputcsv($handle, [
|
||||
$operation->id,
|
||||
$operation->type,
|
||||
$operation->status,
|
||||
$operation->outcome,
|
||||
$includePii ? ($operation->user?->name ?? '') : '[REDACTED]',
|
||||
$operation->started_at?->toIso8601String(),
|
||||
$operation->completed_at?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
rewind($handle);
|
||||
$content = stream_get_contents($handle);
|
||||
fclose($handle);
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact PII from a report payload.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function redactReportPayload(array $payload, bool $includePii): array
|
||||
{
|
||||
if ($includePii) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
return $this->redactArrayPii($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively redact PII fields from an array.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function redactArrayPii(array $data): array
|
||||
{
|
||||
$piiKeys = ['displayName', 'display_name', 'userPrincipalName', 'user_principal_name', 'email', 'mail'];
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_string($key) && in_array($key, $piiKeys, true)) {
|
||||
$data[$key] = '[REDACTED]';
|
||||
} elseif (is_array($value)) {
|
||||
$data[$key] = $this->redactArrayPii($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble a ZIP file from a file map.
|
||||
*
|
||||
* @param array<string, string> $fileMap
|
||||
*/
|
||||
private function assembleZip(string $tempFile, array $fileMap): void
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$result = $zip->open($tempFile, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
||||
|
||||
if ($result !== true) {
|
||||
throw new \RuntimeException("Failed to create ZIP archive: error code {$result}");
|
||||
}
|
||||
|
||||
// Add files in alphabetical order for deterministic output
|
||||
ksort($fileMap);
|
||||
|
||||
foreach ($fileMap as $filename => $content) {
|
||||
$zip->addFromString($filename, $content);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
private function markFailed(ReviewPack $reviewPack, OperationRun $operationRun, string $reasonCode, string $errorMessage): void
|
||||
{
|
||||
$reviewPack->update(['status' => ReviewPackStatus::Failed->value]);
|
||||
|
||||
$operationRun->update([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'completed_at' => now(),
|
||||
'context' => array_merge($operationRun->context ?? [], [
|
||||
'reason_code' => $reasonCode,
|
||||
'error_message' => mb_substr($errorMessage, 0, 500),
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->notifyInitiator($reviewPack, 'failed', $reasonCode);
|
||||
}
|
||||
|
||||
private function notifyInitiator(ReviewPack $reviewPack, string $status, ?string $reasonCode = null): void
|
||||
{
|
||||
$initiator = $reviewPack->initiator;
|
||||
|
||||
if (! $initiator) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$initiator->notify(new ReviewPackStatusNotification($reviewPack, $status, $reasonCode));
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Failed to send ReviewPack notification', [
|
||||
'review_pack_id' => $reviewPack->getKey(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
133
app/Models/ReviewPack.php
Normal file
133
app/Models/ReviewPack.php
Normal file
@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ReviewPack extends Model
|
||||
{
|
||||
use DerivesWorkspaceIdFromTenant;
|
||||
use HasFactory;
|
||||
|
||||
public const string STATUS_QUEUED = 'queued';
|
||||
|
||||
public const string STATUS_GENERATING = 'generating';
|
||||
|
||||
public const string STATUS_READY = 'ready';
|
||||
|
||||
public const string STATUS_FAILED = 'failed';
|
||||
|
||||
public const string STATUS_EXPIRED = 'expired';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'summary' => 'array',
|
||||
'options' => 'array',
|
||||
'generated_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'file_size' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Workspace, $this>
|
||||
*/
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<OperationRun, $this>
|
||||
*/
|
||||
public function operationRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OperationRun::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function initiator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'initiated_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeReady(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_READY);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeExpired(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_EXPIRED);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopePastRetention(Builder $query): Builder
|
||||
{
|
||||
return $query->where('expires_at', '<', now());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeForTenant(Builder $query, int $tenantId): Builder
|
||||
{
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeLatestReady(Builder $query): Builder
|
||||
{
|
||||
return $query->ready()->latest('generated_at');
|
||||
}
|
||||
|
||||
public function isReady(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_READY;
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_EXPIRED;
|
||||
}
|
||||
|
||||
public function getStatusEnum(): ReviewPackStatus
|
||||
{
|
||||
return ReviewPackStatus::from($this->status);
|
||||
}
|
||||
}
|
||||
91
app/Notifications/ReviewPackStatusNotification.php
Normal file
91
app/Notifications/ReviewPackStatusNotification.php
Normal file
@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class ReviewPackStatusNotification extends Notification
|
||||
{
|
||||
public function __construct(
|
||||
public ReviewPack $reviewPack,
|
||||
public string $status,
|
||||
public ?string $reasonCode = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
$title = match ($this->status) {
|
||||
'ready' => 'Review Pack ready',
|
||||
'failed' => 'Review Pack generation failed',
|
||||
default => 'Review Pack status updated',
|
||||
};
|
||||
|
||||
$body = match ($this->status) {
|
||||
'ready' => 'Your tenant review pack has been generated and is ready for download.',
|
||||
'failed' => sprintf(
|
||||
'Review pack generation failed%s.',
|
||||
$this->reasonCode ? ": {$this->reasonCode}" : '',
|
||||
),
|
||||
default => 'Review pack status changed.',
|
||||
};
|
||||
|
||||
$color = match ($this->status) {
|
||||
'ready' => 'success',
|
||||
'failed' => 'danger',
|
||||
default => 'gray',
|
||||
};
|
||||
|
||||
$icon = match ($this->status) {
|
||||
'ready' => 'heroicon-o-document-arrow-down',
|
||||
'failed' => 'heroicon-o-exclamation-triangle',
|
||||
default => 'heroicon-o-document-text',
|
||||
};
|
||||
|
||||
$actions = [];
|
||||
$tenant = $this->reviewPack->tenant;
|
||||
|
||||
if ($tenant instanceof Tenant && $this->status === 'ready') {
|
||||
$actions[] = Action::make('view_pack')
|
||||
->label('View pack')
|
||||
->url(route('filament.admin.resources.review-packs.view', [
|
||||
'tenant' => $tenant->external_id,
|
||||
'record' => $this->reviewPack->getKey(),
|
||||
]))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
return [
|
||||
'format' => 'filament',
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'color' => $color,
|
||||
'duration' => 'persistent',
|
||||
'actions' => $actions,
|
||||
'icon' => $icon,
|
||||
'iconColor' => $color,
|
||||
'status' => null,
|
||||
'view' => null,
|
||||
'viewData' => [
|
||||
'review_pack_id' => $this->reviewPack->getKey(),
|
||||
'status' => $this->status,
|
||||
'reason_code' => $this->reasonCode,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
97
app/Policies/ReviewPackPolicy.php
Normal file
97
app/Policies/ReviewPackPolicy.php
Normal file
@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
class ReviewPackPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $tenant, Capabilities::REVIEW_PACK_VIEW);
|
||||
}
|
||||
|
||||
public function view(User $user, ReviewPack $reviewPack): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $reviewPack->tenant_id !== (int) $tenant->getKey()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $tenant, Capabilities::REVIEW_PACK_VIEW);
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $tenant, Capabilities::REVIEW_PACK_MANAGE);
|
||||
}
|
||||
|
||||
public function delete(User $user, ReviewPack $reviewPack): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $reviewPack->tenant_id !== (int) $tenant->getKey()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $tenant, Capabilities::REVIEW_PACK_MANAGE);
|
||||
}
|
||||
}
|
||||
@ -40,6 +40,9 @@ class RoleCapabilityMap
|
||||
|
||||
Capabilities::ENTRA_ROLES_VIEW,
|
||||
Capabilities::ENTRA_ROLES_MANAGE,
|
||||
|
||||
Capabilities::REVIEW_PACK_VIEW,
|
||||
Capabilities::REVIEW_PACK_MANAGE,
|
||||
],
|
||||
|
||||
TenantRole::Manager->value => [
|
||||
@ -65,6 +68,9 @@ class RoleCapabilityMap
|
||||
|
||||
Capabilities::ENTRA_ROLES_VIEW,
|
||||
Capabilities::ENTRA_ROLES_MANAGE,
|
||||
|
||||
Capabilities::REVIEW_PACK_VIEW,
|
||||
Capabilities::REVIEW_PACK_MANAGE,
|
||||
],
|
||||
|
||||
TenantRole::Operator->value => [
|
||||
@ -84,6 +90,8 @@ class RoleCapabilityMap
|
||||
Capabilities::AUDIT_VIEW,
|
||||
|
||||
Capabilities::ENTRA_ROLES_VIEW,
|
||||
|
||||
Capabilities::REVIEW_PACK_VIEW,
|
||||
],
|
||||
|
||||
TenantRole::Readonly->value => [
|
||||
@ -97,6 +105,8 @@ class RoleCapabilityMap
|
||||
Capabilities::AUDIT_VIEW,
|
||||
|
||||
Capabilities::ENTRA_ROLES_VIEW,
|
||||
|
||||
Capabilities::REVIEW_PACK_VIEW,
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
151
app/Services/ReviewPackService.php
Normal file
151
app/Services/ReviewPackService.php
Normal file
@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\GenerateReviewPackJob;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class ReviewPackService
|
||||
{
|
||||
public function __construct(
|
||||
private OperationRunService $operationRunService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create an OperationRun + ReviewPack and dispatch the generation job.
|
||||
*
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function generate(Tenant $tenant, User $user, array $options = []): ReviewPack
|
||||
{
|
||||
$options = $this->normalizeOptions($options);
|
||||
$fingerprint = $this->computeFingerprint($tenant, $options);
|
||||
|
||||
$existing = $this->findExistingPack($tenant, $fingerprint);
|
||||
if ($existing instanceof ReviewPack) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$operationRun = $this->operationRunService->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::ReviewPackGenerate->value,
|
||||
inputs: [
|
||||
'include_pii' => $options['include_pii'],
|
||||
'include_operations' => $options['include_operations'],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$reviewPack = ReviewPack::create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'operation_run_id' => (int) $operationRun->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'status' => ReviewPackStatus::Queued->value,
|
||||
'options' => $options,
|
||||
'summary' => [],
|
||||
]);
|
||||
|
||||
GenerateReviewPackJob::dispatch(
|
||||
reviewPackId: (int) $reviewPack->getKey(),
|
||||
operationRunId: (int) $operationRun->getKey(),
|
||||
);
|
||||
|
||||
return $reviewPack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a deterministic fingerprint for deduplication.
|
||||
*
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function computeFingerprint(Tenant $tenant, array $options): string
|
||||
{
|
||||
$reportFingerprints = StoredReport::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->whereIn('report_type', [
|
||||
StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||
])
|
||||
->orderBy('report_type')
|
||||
->pluck('fingerprint')
|
||||
->toArray();
|
||||
|
||||
$maxFindingDate = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_ACKNOWLEDGED])
|
||||
->max('updated_at');
|
||||
|
||||
$data = [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'include_pii' => (bool) ($options['include_pii'] ?? true),
|
||||
'include_operations' => (bool) ($options['include_operations'] ?? true),
|
||||
'report_fingerprints' => $reportFingerprints,
|
||||
'max_finding_date' => $maxFindingDate,
|
||||
'rbac_last_checked_at' => $tenant->rbac_last_checked_at?->toIso8601String(),
|
||||
];
|
||||
|
||||
return hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a signed download URL for a review pack.
|
||||
*/
|
||||
public function generateDownloadUrl(ReviewPack $pack): string
|
||||
{
|
||||
$ttlMinutes = (int) config('tenantpilot.review_pack.download_url_ttl_minutes', 60);
|
||||
|
||||
return URL::signedRoute(
|
||||
'admin.review-packs.download',
|
||||
['reviewPack' => $pack->getKey()],
|
||||
now()->addMinutes($ttlMinutes),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an existing ready, non-expired pack with the same fingerprint.
|
||||
*/
|
||||
public function findExistingPack(Tenant $tenant, string $fingerprint): ?ReviewPack
|
||||
{
|
||||
return ReviewPack::query()
|
||||
->forTenant((int) $tenant->getKey())
|
||||
->ready()
|
||||
->where('fingerprint', $fingerprint)
|
||||
->where('expires_at', '>', now())
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a generation run is currently active for this tenant.
|
||||
*/
|
||||
public function checkActiveRun(Tenant $tenant): bool
|
||||
{
|
||||
return OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', OperationRunType::ReviewPackGenerate->value)
|
||||
->active()
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
* @return array{include_pii: bool, include_operations: bool}
|
||||
*/
|
||||
private function normalizeOptions(array $options): array
|
||||
{
|
||||
return [
|
||||
'include_pii' => (bool) ($options['include_pii'] ?? config('tenantpilot.review_pack.include_pii_default', true)),
|
||||
'include_operations' => (bool) ($options['include_operations'] ?? config('tenantpilot.review_pack.include_operations_default', true)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -109,6 +109,11 @@ class Capabilities
|
||||
|
||||
public const ENTRA_ROLES_MANAGE = 'entra_roles.manage';
|
||||
|
||||
// Review packs
|
||||
public const REVIEW_PACK_VIEW = 'review_pack.view';
|
||||
|
||||
public const REVIEW_PACK_MANAGE = 'review_pack.manage';
|
||||
|
||||
/**
|
||||
* Get all capability constants
|
||||
*
|
||||
|
||||
@ -41,6 +41,7 @@ final class BadgeCatalog
|
||||
BadgeDomain::AlertDestinationLastTestStatus->value => Domains\AlertDestinationLastTestStatusBadge::class,
|
||||
BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class,
|
||||
BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class,
|
||||
BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -33,4 +33,5 @@ enum BadgeDomain: string
|
||||
case AlertDestinationLastTestStatus = 'alert_destination_last_test_status';
|
||||
case BaselineProfileStatus = 'baseline_profile_status';
|
||||
case FindingType = 'finding_type';
|
||||
case ReviewPackStatus = 'review_pack_status';
|
||||
}
|
||||
|
||||
27
app/Support/Badges/Domains/ReviewPackStatusBadge.php
Normal file
27
app/Support/Badges/Domains/ReviewPackStatusBadge.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\ReviewPackStatus;
|
||||
|
||||
final class ReviewPackStatusBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
ReviewPackStatus::Queued->value => new BadgeSpec('Queued', 'warning', 'heroicon-m-clock'),
|
||||
ReviewPackStatus::Generating->value => new BadgeSpec('Generating', 'info', 'heroicon-m-arrow-path'),
|
||||
ReviewPackStatus::Ready->value => new BadgeSpec('Ready', 'success', 'heroicon-m-check-circle'),
|
||||
ReviewPackStatus::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||
ReviewPackStatus::Expired->value => new BadgeSpec('Expired', 'gray', 'heroicon-m-archive-box'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,7 @@ enum OperationRunType: string
|
||||
case DirectoryRoleDefinitionsSync = 'directory_role_definitions.sync';
|
||||
case RestoreExecute = 'restore.execute';
|
||||
case EntraAdminRolesScan = 'entra.admin_roles.scan';
|
||||
case ReviewPackGenerate = 'tenant.review_pack.generate';
|
||||
|
||||
public static function values(): array
|
||||
{
|
||||
|
||||
14
app/Support/ReviewPackStatus.php
Normal file
14
app/Support/ReviewPackStatus.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
enum ReviewPackStatus: string
|
||||
{
|
||||
case Queued = 'queued';
|
||||
case Generating = 'generating';
|
||||
case Ready = 'ready';
|
||||
case Failed = 'failed';
|
||||
case Expired = 'expired';
|
||||
}
|
||||
@ -60,6 +60,13 @@
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'exports' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private/exports'),
|
||||
'serve' => false,
|
||||
'throw' => true,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@ -357,4 +357,12 @@
|
||||
'show_script_content' => (bool) env('TENANTPILOT_SHOW_SCRIPT_CONTENT', false),
|
||||
'max_script_content_chars' => (int) env('TENANTPILOT_MAX_SCRIPT_CONTENT_CHARS', 5000),
|
||||
],
|
||||
|
||||
'review_pack' => [
|
||||
'retention_days' => (int) env('TENANTPILOT_REVIEW_PACK_RETENTION_DAYS', 90),
|
||||
'hard_delete_grace_days' => (int) env('TENANTPILOT_REVIEW_PACK_HARD_DELETE_GRACE_DAYS', 30),
|
||||
'download_url_ttl_minutes' => (int) env('TENANTPILOT_REVIEW_PACK_DOWNLOAD_URL_TTL_MINUTES', 60),
|
||||
'include_pii_default' => (bool) env('TENANTPILOT_REVIEW_PACK_INCLUDE_PII_DEFAULT', true),
|
||||
'include_operations_default' => (bool) env('TENANTPILOT_REVIEW_PACK_INCLUDE_OPERATIONS_DEFAULT', true),
|
||||
],
|
||||
];
|
||||
|
||||
133
database/factories/ReviewPackFactory.php
Normal file
133
database/factories/ReviewPackFactory.php
Normal file
@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<ReviewPack>
|
||||
*/
|
||||
class ReviewPackFactory extends Factory
|
||||
{
|
||||
protected $model = ReviewPack::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory()->for(Workspace::factory()),
|
||||
'workspace_id' => function (array $attributes): int {
|
||||
$tenantId = $attributes['tenant_id'] ?? null;
|
||||
|
||||
if (! is_numeric($tenantId)) {
|
||||
return (int) Workspace::factory()->create()->getKey();
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->whereKey((int) $tenantId)->first();
|
||||
|
||||
if (! $tenant instanceof Tenant || $tenant->workspace_id === null) {
|
||||
return (int) Workspace::factory()->create()->getKey();
|
||||
}
|
||||
|
||||
return (int) $tenant->workspace_id;
|
||||
},
|
||||
'initiated_by_user_id' => User::factory(),
|
||||
'status' => ReviewPackStatus::Ready->value,
|
||||
'fingerprint' => fake()->sha256(),
|
||||
'previous_fingerprint' => null,
|
||||
'summary' => [
|
||||
'finding_count' => fake()->numberBetween(0, 100),
|
||||
'report_count' => fake()->numberBetween(0, 10),
|
||||
],
|
||||
'options' => [
|
||||
'include_pii' => true,
|
||||
'include_operations' => true,
|
||||
],
|
||||
'file_disk' => 'exports',
|
||||
'file_path' => fn () => 'review-packs/'.fake()->uuid().'.zip',
|
||||
'file_size' => fake()->numberBetween(1024, 1048576),
|
||||
'sha256' => fake()->sha256(),
|
||||
'generated_at' => now(),
|
||||
'expires_at' => now()->addDays(90),
|
||||
];
|
||||
}
|
||||
|
||||
public function queued(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => ReviewPackStatus::Queued->value,
|
||||
'fingerprint' => null,
|
||||
'file_disk' => null,
|
||||
'file_path' => null,
|
||||
'file_size' => null,
|
||||
'sha256' => null,
|
||||
'generated_at' => null,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function generating(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => ReviewPackStatus::Generating->value,
|
||||
'fingerprint' => null,
|
||||
'file_disk' => null,
|
||||
'file_path' => null,
|
||||
'file_size' => null,
|
||||
'sha256' => null,
|
||||
'generated_at' => null,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function ready(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => ReviewPackStatus::Ready->value,
|
||||
'fingerprint' => fake()->sha256(),
|
||||
'file_disk' => 'exports',
|
||||
'file_path' => 'review-packs/'.fake()->uuid().'.zip',
|
||||
'file_size' => fake()->numberBetween(1024, 1048576),
|
||||
'sha256' => fake()->sha256(),
|
||||
'generated_at' => now(),
|
||||
'expires_at' => now()->addDays(90),
|
||||
]);
|
||||
}
|
||||
|
||||
public function failed(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => ReviewPackStatus::Failed->value,
|
||||
'fingerprint' => null,
|
||||
'file_disk' => null,
|
||||
'file_path' => null,
|
||||
'file_size' => null,
|
||||
'sha256' => null,
|
||||
'generated_at' => null,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function expired(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => ReviewPackStatus::Expired->value,
|
||||
'fingerprint' => fake()->sha256(),
|
||||
'file_disk' => null,
|
||||
'file_path' => null,
|
||||
'file_size' => null,
|
||||
'sha256' => null,
|
||||
'generated_at' => now()->subDays(91),
|
||||
'expires_at' => now()->subDay(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('review_packs', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
|
||||
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
|
||||
$table->foreignId('operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete();
|
||||
$table->foreignId('initiated_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('status')->default('queued');
|
||||
$table->string('fingerprint', 64)->nullable();
|
||||
$table->string('previous_fingerprint', 64)->nullable();
|
||||
$table->jsonb('summary')->default('{}');
|
||||
$table->jsonb('options')->default('{}');
|
||||
$table->string('file_disk')->nullable();
|
||||
$table->string('file_path')->nullable();
|
||||
$table->bigInteger('file_size')->nullable();
|
||||
$table->string('sha256', 64)->nullable();
|
||||
$table->timestampTz('generated_at')->nullable();
|
||||
$table->timestampTz('expires_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['workspace_id', 'tenant_id', 'generated_at']);
|
||||
$table->index(['status', 'expires_at']);
|
||||
});
|
||||
|
||||
DB::statement('
|
||||
CREATE UNIQUE INDEX review_packs_fingerprint_unique
|
||||
ON review_packs (workspace_id, tenant_id, fingerprint)
|
||||
WHERE fingerprint IS NOT NULL AND status NOT IN (\'expired\', \'failed\')
|
||||
');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('review_packs');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,164 @@
|
||||
@php
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\ReviewPackStatus;
|
||||
|
||||
/** @var ?\App\Models\Tenant $tenant */
|
||||
/** @var ?\App\Models\ReviewPack $pack */
|
||||
/** @var ?ReviewPackStatus $statusEnum */
|
||||
/** @var bool $canView */
|
||||
/** @var bool $canManage */
|
||||
/** @var ?string $downloadUrl */
|
||||
/** @var ?string $failedReason */
|
||||
|
||||
$badgeSpec = $statusEnum ? BadgeCatalog::spec(BadgeDomain::ReviewPackStatus, $statusEnum->value) : null;
|
||||
@endphp
|
||||
|
||||
<x-filament::section heading="Review Pack">
|
||||
@if (! $pack)
|
||||
{{-- State 1: No pack --}}
|
||||
<div class="flex flex-col items-center gap-3 py-4 text-center">
|
||||
<x-heroicon-o-document-arrow-down class="h-8 w-8 text-gray-400 dark:text-gray-500" />
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
No review pack generated yet.
|
||||
</div>
|
||||
|
||||
@if ($canManage)
|
||||
<x-filament::button
|
||||
size="sm"
|
||||
wire:click="generatePack"
|
||||
wire:loading.attr="disabled"
|
||||
>
|
||||
Generate pack
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
@elseif ($statusEnum === ReviewPackStatus::Queued || $statusEnum === ReviewPackStatus::Generating)
|
||||
{{-- State 2: Queued / Generating --}}
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::badge
|
||||
:color="$badgeSpec?->color"
|
||||
:icon="$badgeSpec?->icon"
|
||||
>
|
||||
{{ $badgeSpec?->label ?? '—' }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<x-filament::loading-indicator class="h-4 w-4" />
|
||||
Generation in progress…
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500">
|
||||
Started {{ $pack->created_at?->diffForHumans() ?? '—' }}
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($statusEnum === ReviewPackStatus::Ready)
|
||||
{{-- State 3: Ready --}}
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::badge
|
||||
:color="$badgeSpec?->color"
|
||||
:icon="$badgeSpec?->icon"
|
||||
>
|
||||
{{ $badgeSpec?->label ?? '—' }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Generated</dt>
|
||||
<dd>{{ $pack->generated_at?->format('M j, Y H:i') ?? '—' }}</dd>
|
||||
|
||||
<dt class="text-gray-500 dark:text-gray-400">Expires</dt>
|
||||
<dd>{{ $pack->expires_at?->format('M j, Y') ?? '—' }}</dd>
|
||||
|
||||
<dt class="text-gray-500 dark:text-gray-400">Size</dt>
|
||||
<dd>{{ $pack->file_size ? Number::fileSize($pack->file_size) : '—' }}</dd>
|
||||
</dl>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@if ($canView && $downloadUrl)
|
||||
<x-filament::button
|
||||
size="sm"
|
||||
tag="a"
|
||||
:href="$downloadUrl"
|
||||
target="_blank"
|
||||
icon="heroicon-o-arrow-down-tray"
|
||||
>
|
||||
Download
|
||||
</x-filament::button>
|
||||
@endif
|
||||
|
||||
@if ($canManage)
|
||||
<x-filament::button
|
||||
size="sm"
|
||||
color="gray"
|
||||
wire:click="generatePack"
|
||||
wire:loading.attr="disabled"
|
||||
>
|
||||
Generate new
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($statusEnum === ReviewPackStatus::Failed)
|
||||
{{-- State 4: Failed --}}
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::badge
|
||||
:color="$badgeSpec?->color"
|
||||
:icon="$badgeSpec?->icon"
|
||||
>
|
||||
{{ $badgeSpec?->label ?? '—' }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
@if ($failedReason)
|
||||
<div class="text-sm text-danger-600 dark:text-danger-400">
|
||||
{{ $failedReason }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ $pack->updated_at?->diffForHumans() ?? '—' }}
|
||||
</div>
|
||||
|
||||
@if ($canManage)
|
||||
<x-filament::button
|
||||
size="sm"
|
||||
wire:click="generatePack"
|
||||
wire:loading.attr="disabled"
|
||||
>
|
||||
Retry
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
@elseif ($statusEnum === ReviewPackStatus::Expired)
|
||||
{{-- State 5: Expired --}}
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::badge
|
||||
:color="$badgeSpec?->color"
|
||||
:icon="$badgeSpec?->icon"
|
||||
>
|
||||
{{ $badgeSpec?->label ?? '—' }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Expired {{ $pack->expires_at?->diffForHumans() ?? '—' }}
|
||||
</div>
|
||||
|
||||
@if ($canManage)
|
||||
<x-filament::button
|
||||
size="sm"
|
||||
wire:click="generatePack"
|
||||
wire:loading.attr="disabled"
|
||||
>
|
||||
Generate new
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
@ -34,6 +34,11 @@
|
||||
->name('stored-reports:prune')
|
||||
->withoutOverlapping();
|
||||
|
||||
Schedule::command('tenantpilot:review-pack:prune')
|
||||
->daily()
|
||||
->name('tenantpilot:review-pack:prune')
|
||||
->withoutOverlapping();
|
||||
|
||||
Schedule::call(function (): void {
|
||||
$tenants = Tenant::query()
|
||||
->whereHas('providerConnections', function ($q): void {
|
||||
@ -52,3 +57,6 @@
|
||||
->daily()
|
||||
->name('entra-admin-roles:scan')
|
||||
->withoutOverlapping();
|
||||
|
||||
// TODO: Add tenantpilot:posture:dispatch schedule entry once the command
|
||||
// infrastructure exists (FR-015 deferred — see specs/109 research.md §7).
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
use App\Http\Controllers\Auth\EntraController;
|
||||
use App\Http\Controllers\ClearTenantContextController;
|
||||
use App\Http\Controllers\RbacDelegatedAuthController;
|
||||
use App\Http\Controllers\ReviewPackDownloadController;
|
||||
use App\Http\Controllers\SelectTenantController;
|
||||
use App\Http\Controllers\SwitchWorkspaceController;
|
||||
use App\Http\Controllers\TenantOnboardingController;
|
||||
@ -206,6 +207,10 @@
|
||||
->get('/admin/w/{workspace}/managed-tenants', \App\Filament\Pages\Workspaces\ManagedTenantsLanding::class)
|
||||
->name('admin.workspace.managed-tenants.index');
|
||||
|
||||
Route::middleware(['signed'])
|
||||
->get('/admin/review-packs/{reviewPack}/download', ReviewPackDownloadController::class)
|
||||
->name('admin.review-packs.download');
|
||||
|
||||
if (app()->runningUnitTests()) {
|
||||
Route::middleware(['web', 'auth', 'ensure-workspace-selected'])
|
||||
->get('/admin/_test/workspace-context', function (Request $request) {
|
||||
|
||||
@ -31,7 +31,7 @@ ## Phase 1: Setup
|
||||
|
||||
**Purpose**: Database schema — must complete before model/factory creation
|
||||
|
||||
- [ ] T001 Create migration for `review_packs` table with all columns (id, workspace_id FK, tenant_id FK, operation_run_id FK nullable, initiated_by_user_id FK nullable, status, fingerprint, previous_fingerprint, summary jsonb, options jsonb, file_disk, file_path, file_size bigint, sha256, generated_at timestampTz, expires_at timestampTz, timestamps), three indexes, and partial unique index `WHERE fingerprint IS NOT NULL AND status NOT IN ('expired','failed')` in `database/migrations/XXXX_create_review_packs_table.php`
|
||||
- [X] T001 Create migration for `review_packs` table with all columns (id, workspace_id FK, tenant_id FK, operation_run_id FK nullable, initiated_by_user_id FK nullable, status, fingerprint, previous_fingerprint, summary jsonb, options jsonb, file_disk, file_path, file_size bigint, sha256, generated_at timestampTz, expires_at timestampTz, timestamps), three indexes, and partial unique index `WHERE fingerprint IS NOT NULL AND status NOT IN ('expired','failed')` in `database/migrations/XXXX_create_review_packs_table.php`
|
||||
|
||||
---
|
||||
|
||||
@ -41,16 +41,16 @@ ## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
||||
|
||||
- [ ] T002 [P] Create `ReviewPackStatus` enum with 5 string-backed cases (Queued, Generating, Ready, Failed, Expired) in `app/Support/ReviewPackStatus.php`
|
||||
- [ ] T003 [P] Add `ReviewPackGenerate` case with value `tenant.review_pack.generate` to `app/Support/OperationRunType.php`
|
||||
- [ ] T004 [P] Add `REVIEW_PACK_VIEW = 'review_pack.view'` and `REVIEW_PACK_MANAGE = 'review_pack.manage'` constants to `app/Support/Auth/Capabilities.php`
|
||||
- [ ] T005 [P] Add `ReviewPackStatus = 'review_pack_status'` case to `app/Support/Badges/BadgeDomain.php`
|
||||
- [ ] T006 [P] Create `ReviewPackStatusBadge` mapper returning `BadgeSpec` for 5 statuses (queued→warning, generating→info, ready→success, failed→danger, expired→gray) in `app/Support/Badges/Mappers/ReviewPackStatusBadge.php`
|
||||
- [ ] T007 [P] Add `exports` disk with local driver at `storage_path('app/private/exports')`, `serve => false`, `throw => true` to `config/filesystems.php`
|
||||
- [ ] T008 [P] Add `review_pack` config section with retention_days (90), hard_delete_grace_days (30), download_url_ttl_minutes (60), include_pii_default (true), include_operations_default (true) to `config/tenantpilot.php`
|
||||
- [ ] T009 Create `ReviewPack` model with `DerivesWorkspaceIdFromTenant` + `HasFactory` traits, `guarded = []`, casts (summary/options→array, generated_at/expires_at→datetime, file_size→integer), relationships (workspace, tenant, operationRun, initiator via initiated_by_user_id), scopes (ready, expired, pastRetention, forTenant, latestReady), and STATUS_* constants in `app/Models/ReviewPack.php`
|
||||
- [ ] T010 Create `ReviewPackFactory` with default definition (ready state) and 5 named states (queued, generating, ready, failed, expired) that set correct status + nullable file fields per state in `database/factories/ReviewPackFactory.php`
|
||||
- [ ] T011 Run migration and verify model + factory (`vendor/bin/sail artisan migrate && vendor/bin/sail artisan test --compact --filter=ReviewPack`)
|
||||
- [X] T002 [P] Create `ReviewPackStatus` enum with 5 string-backed cases (Queued, Generating, Ready, Failed, Expired) in `app/Support/ReviewPackStatus.php`
|
||||
- [X] T003 [P] Add `ReviewPackGenerate` case with value `tenant.review_pack.generate` to `app/Support/OperationRunType.php`
|
||||
- [X] T004 [P] Add `REVIEW_PACK_VIEW = 'review_pack.view'` and `REVIEW_PACK_MANAGE = 'review_pack.manage'` constants to `app/Support/Auth/Capabilities.php`
|
||||
- [X] T005 [P] Add `ReviewPackStatus = 'review_pack_status'` case to `app/Support/Badges/BadgeDomain.php`
|
||||
- [X] T006 [P] Create `ReviewPackStatusBadge` mapper returning `BadgeSpec` for 5 statuses (queued→warning, generating→info, ready→success, failed→danger, expired→gray) in `app/Support/Badges/Mappers/ReviewPackStatusBadge.php`
|
||||
- [X] T007 [P] Add `exports` disk with local driver at `storage_path('app/private/exports')`, `serve => false`, `throw => true` to `config/filesystems.php`
|
||||
- [X] T008 [P] Add `review_pack` config section with retention_days (90), hard_delete_grace_days (30), download_url_ttl_minutes (60), include_pii_default (true), include_operations_default (true) to `config/tenantpilot.php`
|
||||
- [X] T009 Create `ReviewPack` model with `DerivesWorkspaceIdFromTenant` + `HasFactory` traits, `guarded = []`, casts (summary/options→array, generated_at/expires_at→datetime, file_size→integer), relationships (workspace, tenant, operationRun, initiator via initiated_by_user_id), scopes (ready, expired, pastRetention, forTenant, latestReady), and STATUS_* constants in `app/Models/ReviewPack.php`
|
||||
- [X] T010 Create `ReviewPackFactory` with default definition (ready state) and 5 named states (queued, generating, ready, failed, expired) that set correct status + nullable file fields per state in `database/factories/ReviewPackFactory.php`
|
||||
- [X] T011 Run migration and verify model + factory (`vendor/bin/sail artisan migrate && vendor/bin/sail artisan test --compact --filter=ReviewPack`)
|
||||
|
||||
**Checkpoint**: Foundation ready — user story implementation can now begin
|
||||
|
||||
@ -64,19 +64,19 @@ ## Phase 3: User Story 1 — Generate and Download Review Pack (Priority: P1)
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [ ] T012 [US1] Implement `ReviewPackService` with `generate(Tenant, User, array $options): ReviewPack` (creates OperationRun + ReviewPack + dispatches job), `computeFingerprint(Tenant, array $options): string` (SHA-256 of tenant_id + options + report fingerprints + max finding last_seen_at + hardening tuple), `generateDownloadUrl(ReviewPack): string` (URL::signedRoute with configurable TTL), `findExistingPack(Tenant, string $fingerprint): ?ReviewPack` (ready + unexpired dedupe check), and `checkActiveRun(Tenant): bool` (active OperationRun guard) in `app/Services/ReviewPackService.php`
|
||||
- [ ] T013 [US1] Implement `GenerateReviewPackJob` (ShouldQueue) with 12-step pipeline: load records, mark running, collect StoredReports (permission_posture + entra.admin_roles), collect Findings (status in open/acknowledged, chunked), collect tenant hardening fields (via accessor, not raw DB), collect recent OperationRuns (30 days), compute data_freshness, build file map with PII redaction when include_pii=false, assemble ZIP via ZipArchive (alphabetical order, temp file), compute SHA-256, store on exports disk, update ReviewPack (status=ready, fingerprint, sha256, file_size, file_path, file_disk, generated_at, summary, expires_at), mark OperationRun completed; on failure: mark failed with reason_code in context, notify initiator in `app/Jobs/GenerateReviewPackJob.php`
|
||||
- [ ] T014 [P] [US1] Create `ReviewPackStatusNotification` (database channel) with conditional rendering: ready payload (title, body with tenant name, View action URL) and failed payload (title, body with sanitized reason, View action URL) in `app/Notifications/ReviewPackStatusNotification.php`
|
||||
- [ ] T015 [US1] Create `ReviewPackDownloadController` with single `__invoke(Request, ReviewPack)` method: validate status is ready, stream file via `Storage::disk($pack->file_disk)->download()` with headers Content-Type application/zip, Content-Disposition attachment with filename `review-pack-{tenant_external_id}-{YYYY-MM-DD}.zip`, X-Review-Pack-SHA256; return 404 for expired/non-ready packs in `app/Http/Controllers/ReviewPackDownloadController.php`
|
||||
- [ ] T016 [US1] Add signed download route `GET /admin/review-packs/{reviewPack}/download` named `admin.review-packs.download` with `signed` middleware in `routes/web.php`
|
||||
- [ ] T017 [US1] Create `ReviewPackResource` with: table columns (status badge via BADGE-001, tenant name, generated_at datetime, expires_at datetime, file_size formatted), `recordUrl()` for clickable rows to ViewReviewPack, header action "Generate Pack" (modal with Section containing include_pii Toggle + include_operations Toggle, authorized by REVIEW_PACK_MANAGE, calls ReviewPackService::generate), row actions "Download" (visible when ready, REVIEW_PACK_VIEW, openUrlInNewTab with signed URL) + "Expire" (REVIEW_PACK_MANAGE, color danger, requiresConfirmation, sets status=expired + deletes file), empty state with title "No review packs yet" + description + "Generate first pack" CTA, filters (status SelectFilter, generated_at date range), search on tenant name, sort on generated_at/status, nav group Monitoring → Exports in `app/Filament/Resources/ReviewPackResource.php`
|
||||
- [ ] T018 [US1] Create `ListReviewPacks` page extending ListRecords in `app/Filament/Resources/ReviewPackResource/Pages/ListReviewPacks.php`
|
||||
- [ ] T019 [US1] Create `ViewReviewPack` page with Infolist layout: status badge (BADGE-001), summary section (data_freshness per-source timestamps, generated_at, expires_at, file_size, sha256), options section (include_pii, include_operations), initiator + OperationRun link; header actions "Download" (REVIEW_PACK_VIEW, visible when ready) + "Regenerate" (REVIEW_PACK_MANAGE, requiresConfirmation when ready pack exists, pre-fills current options, sets previous_fingerprint) in `app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`
|
||||
- [ ] T020 [P] [US1] Create `TenantReviewPackCard` widget with 5 display states: no pack ("No review pack yet" + Generate CTA), queued/generating (status badge + "Generation in progress"), ready (badge + generated_at + expires_at + file_size + Download action REVIEW_PACK_VIEW + "Generate new" REVIEW_PACK_MANAGE), failed (badge + sanitized reason + Retry=Generate REVIEW_PACK_MANAGE), expired (badge + expiry date + "Generate new" REVIEW_PACK_MANAGE); data = latest ReviewPack for current tenant in `app/Filament/Widgets/TenantReviewPackCard.php`
|
||||
- [ ] T021 [P] [US1] Create generation tests: happy path (job processes, status→ready, file on disk, sha256+file_size populated, notification sent to initiator, OperationRun completed+success), failure path (exception → status→failed, OperationRun failed, reason_code in context, failure notification), empty reports (job succeeds with empty report sections, status→ready), PII redaction (include_pii=false → display_name replaced with placeholder, UUIDs retained), ZIP contents (exactly 7 files in alphabetical order: findings.csv, hardening.json, metadata.json, operations.csv, reports/entra_admin_roles.json, reports/permission_posture.json, summary.json), performance baseline (seed 1,000 findings + 10 stored reports and assert job completes within 60s per SC-001) in `tests/Feature/ReviewPack/ReviewPackGenerationTest.php`
|
||||
- [ ] T022 [P] [US1] Create download tests: signed URL → 200 with Content-Type application/zip + Content-Disposition + X-Review-Pack-SHA256 headers; expired signature → 403; expired pack (status=expired) → 404; non-ready pack (status=queued) → 404; non-existent pack → 404 in `tests/Feature/ReviewPack/ReviewPackDownloadTest.php`
|
||||
- [ ] T023 [P] [US1] Create resource Livewire tests: list page renders with columns, empty state shows CTA, generate action modal opens with toggle fields, view page displays infolist sections with correct data, expire action requires confirmation and updates status in `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
|
||||
- [ ] T024 [P] [US1] Create widget Livewire tests: no-pack state shows generate CTA, ready state shows download + generate actions, generating state shows in-progress message, failed state shows retry action, expired state shows generate action in `tests/Feature/ReviewPack/ReviewPackWidgetTest.php`
|
||||
- [X] T012 [US1] Implement `ReviewPackService` with `generate(Tenant, User, array $options): ReviewPack` (creates OperationRun + ReviewPack + dispatches job), `computeFingerprint(Tenant, array $options): string` (SHA-256 of tenant_id + options + report fingerprints + max finding last_seen_at + hardening tuple), `generateDownloadUrl(ReviewPack): string` (URL::signedRoute with configurable TTL), `findExistingPack(Tenant, string $fingerprint): ?ReviewPack` (ready + unexpired dedupe check), and `checkActiveRun(Tenant): bool` (active OperationRun guard) in `app/Services/ReviewPackService.php`
|
||||
- [X] T013 [US1] Implement `GenerateReviewPackJob` (ShouldQueue) with 12-step pipeline: load records, mark running, collect StoredReports (permission_posture + entra.admin_roles), collect Findings (status in open/acknowledged, chunked), collect tenant hardening fields (via accessor, not raw DB), collect recent OperationRuns (30 days), compute data_freshness, build file map with PII redaction when include_pii=false, assemble ZIP via ZipArchive (alphabetical order, temp file), compute SHA-256, store on exports disk, update ReviewPack (status=ready, fingerprint, sha256, file_size, file_path, file_disk, generated_at, summary, expires_at), mark OperationRun completed; on failure: mark failed with reason_code in context, notify initiator in `app/Jobs/GenerateReviewPackJob.php`
|
||||
- [X] T014 [P] [US1] Create `ReviewPackStatusNotification` (database channel) with conditional rendering: ready payload (title, body with tenant name, View action URL) and failed payload (title, body with sanitized reason, View action URL) in `app/Notifications/ReviewPackStatusNotification.php`
|
||||
- [X] T015 [US1] Create `ReviewPackDownloadController` with single `__invoke(Request, ReviewPack)` method: validate status is ready, stream file via `Storage::disk($pack->file_disk)->download()` with headers Content-Type application/zip, Content-Disposition attachment with filename `review-pack-{tenant_external_id}-{YYYY-MM-DD}.zip`, X-Review-Pack-SHA256; return 404 for expired/non-ready packs in `app/Http/Controllers/ReviewPackDownloadController.php`
|
||||
- [X] T016 [US1] Add signed download route `GET /admin/review-packs/{reviewPack}/download` named `admin.review-packs.download` with `signed` middleware in `routes/web.php`
|
||||
- [X] T017 [US1] Create `ReviewPackResource` with: table columns (status badge via BADGE-001, tenant name, generated_at datetime, expires_at datetime, file_size formatted), `recordUrl()` for clickable rows to ViewReviewPack, header action "Generate Pack" (modal with Section containing include_pii Toggle + include_operations Toggle, authorized by REVIEW_PACK_MANAGE, calls ReviewPackService::generate), row actions "Download" (visible when ready, REVIEW_PACK_VIEW, openUrlInNewTab with signed URL) + "Expire" (REVIEW_PACK_MANAGE, color danger, requiresConfirmation, sets status=expired + deletes file), empty state with title "No review packs yet" + description + "Generate first pack" CTA, filters (status SelectFilter, generated_at date range), search on tenant name, sort on generated_at/status, nav group Monitoring → Exports in `app/Filament/Resources/ReviewPackResource.php`
|
||||
- [X] T018 [US1] Create `ListReviewPacks` page extending ListRecords in `app/Filament/Resources/ReviewPackResource/Pages/ListReviewPacks.php`
|
||||
- [X] T019 [US1] Create `ViewReviewPack` page with Infolist layout: status badge (BADGE-001), summary section (data_freshness per-source timestamps, generated_at, expires_at, file_size, sha256), options section (include_pii, include_operations), initiator + OperationRun link; header actions "Download" (REVIEW_PACK_VIEW, visible when ready) + "Regenerate" (REVIEW_PACK_MANAGE, requiresConfirmation when ready pack exists, pre-fills current options, sets previous_fingerprint) in `app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`
|
||||
- [X] T020 [P] [US1] Create `TenantReviewPackCard` widget with 5 display states: no pack ("No review pack yet" + Generate CTA), queued/generating (status badge + "Generation in progress"), ready (badge + generated_at + expires_at + file_size + Download action REVIEW_PACK_VIEW + "Generate new" REVIEW_PACK_MANAGE), failed (badge + sanitized reason + Retry=Generate REVIEW_PACK_MANAGE), expired (badge + expiry date + "Generate new" REVIEW_PACK_MANAGE); data = latest ReviewPack for current tenant in `app/Filament/Widgets/TenantReviewPackCard.php`
|
||||
- [X] T021 [P] [US1] Create generation tests: happy path (job processes, status→ready, file on disk, sha256+file_size populated, notification sent to initiator, OperationRun completed+success), failure path (exception → status→failed, OperationRun failed, reason_code in context, failure notification), empty reports (job succeeds with empty report sections, status→ready), PII redaction (include_pii=false → display_name replaced with placeholder, UUIDs retained), ZIP contents (exactly 7 files in alphabetical order: findings.csv, hardening.json, metadata.json, operations.csv, reports/entra_admin_roles.json, reports/permission_posture.json, summary.json), performance baseline (seed 1,000 findings + 10 stored reports and assert job completes within 60s per SC-001) in `tests/Feature/ReviewPack/ReviewPackGenerationTest.php`
|
||||
- [X] T022 [P] [US1] Create download tests: signed URL → 200 with Content-Type application/zip + Content-Disposition + X-Review-Pack-SHA256 headers; expired signature → 403; expired pack (status=expired) → 404; non-ready pack (status=queued) → 404; non-existent pack → 404 in `tests/Feature/ReviewPack/ReviewPackDownloadTest.php`
|
||||
- [X] T023 [P] [US1] Create resource Livewire tests: list page renders with columns, empty state shows CTA, generate action modal opens with toggle fields, view page displays infolist sections with correct data, expire action requires confirmation and updates status in `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
|
||||
- [X] T024 [P] [US1] Create widget Livewire tests: no-pack state shows generate CTA, ready state shows download + generate actions, generating state shows in-progress message, failed state shows retry action, expired state shows generate action in `tests/Feature/ReviewPack/ReviewPackWidgetTest.php`
|
||||
|
||||
**Checkpoint**: User Story 1 fully functional — can generate, download, view, and manage review packs
|
||||
|
||||
@ -90,7 +90,7 @@ ## Phase 4: User Story 2 — Fingerprint Dedupe (Priority: P2)
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [ ] T025 [US2] Add fingerprint dedupe integration tests: identical inputs → returns existing ready pack (no new row, no new file), active OperationRun for same tenant → rejection with "generation already in progress" notification, expired pack with same fingerprint → allows new generation (partial unique index excludes expired), different options (include_pii toggled) → new pack with different fingerprint, fingerprint computation is deterministic (same inputs → same hash) in `tests/Feature/ReviewPack/ReviewPackGenerationTest.php`
|
||||
- [X] T025 [US2] Add fingerprint dedupe integration tests: identical inputs → returns existing ready pack (no new row, no new file), active OperationRun for same tenant → rejection with "generation already in progress" notification, expired pack with same fingerprint → allows new generation (partial unique index excludes expired), different options (include_pii toggled) → new pack with different fingerprint, fingerprint computation is deterministic (same inputs → same hash) in `tests/Feature/ReviewPack/ReviewPackGenerationTest.php`
|
||||
|
||||
**Checkpoint**: Fingerprint dedupe verified — duplicate requests handled correctly
|
||||
|
||||
@ -104,8 +104,8 @@ ## Phase 5: User Story 3 — RBAC Enforcement (Priority: P2)
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [ ] T026 [US3] Create `ReviewPackPolicy` with `viewAny`, `view`, `create`, `delete` methods mapping to `REVIEW_PACK_VIEW` (viewAny, view) and `REVIEW_PACK_MANAGE` (create, delete) capability checks via canonical registry; enforce workspace_id + tenant_id scope (non-member → null = 404 semantics) in `app/Policies/ReviewPackPolicy.php`
|
||||
- [ ] T027 [US3] Create comprehensive RBAC enforcement tests: non-member → 404 on list page, non-member → 404 on view page, non-member → 404 on download route; REVIEW_PACK_VIEW member → list succeeds, view succeeds, download signed URL succeeds, generate action hidden/403; REVIEW_PACK_MANAGE member → generate succeeds, expire succeeds, download succeeds; signed URL without valid signature → 403; expire action shows requiresConfirmation dialog before execution in `tests/Feature/ReviewPack/ReviewPackRbacTest.php`
|
||||
- [X] T026 [US3] Create `ReviewPackPolicy` with `viewAny`, `view`, `create`, `delete` methods mapping to `REVIEW_PACK_VIEW` (viewAny, view) and `REVIEW_PACK_MANAGE` (create, delete) capability checks via canonical registry; enforce workspace_id + tenant_id scope (non-member → null = 404 semantics) in `app/Policies/ReviewPackPolicy.php`
|
||||
- [X] T027 [US3] Create comprehensive RBAC enforcement tests: non-member → 404 on list page, non-member → 404 on view page, non-member → 404 on download route; REVIEW_PACK_VIEW member → list succeeds, view succeeds, download signed URL succeeds, generate action hidden/403; REVIEW_PACK_MANAGE member → generate succeeds, expire succeeds, download succeeds; signed URL without valid signature → 403; expire action shows requiresConfirmation dialog before execution in `tests/Feature/ReviewPack/ReviewPackRbacTest.php`
|
||||
|
||||
**Checkpoint**: RBAC enforcement verified across all surfaces (resource, controller, widget)
|
||||
|
||||
@ -119,10 +119,10 @@ ## Phase 6: User Story 4 — Retention & Prune (Priority: P3)
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [ ] T028 [US4] Create `PruneReviewPacksCommand` with signature `tenantpilot:review-pack:prune {--hard-delete}`: query ready packs where expires_at < now → set status=expired + delete file from exports disk; when --hard-delete: query expired packs where updated_at < now - grace_days → hard-delete rows; output summary "{n} packs expired, {m} packs hard-deleted" in `app/Console/Commands/PruneReviewPacksCommand.php`
|
||||
- [ ] T029 [US4] Add prune command schedule entry `tenantpilot:review-pack:prune` with `daily()` + `withoutOverlapping()` in `routes/console.php`
|
||||
- [ ] T030 [P] [US4] Remove or hide `sla_due` event_type option from AlertRuleResource form dropdown (keep EVENT_SLA_DUE constant on model for backward compatibility) in `app/Filament/Resources/AlertRuleResource.php`
|
||||
- [ ] T031 [US4] Create prune tests: past retention → status=expired + file deleted from disk; future retention → unaffected; --hard-delete past grace_days → rows removed from DB; --hard-delete within grace_days → rows kept; command output includes correct counts; AlertRule form no longer shows sla_due option in dropdown in `tests/Feature/ReviewPack/ReviewPackPruneTest.php`
|
||||
- [X] T028 [US4] Create `PruneReviewPacksCommand` with signature `tenantpilot:review-pack:prune {--hard-delete}`: query ready packs where expires_at < now → set status=expired + delete file from exports disk; when --hard-delete: query expired packs where updated_at < now - grace_days → hard-delete rows; output summary "{n} packs expired, {m} packs hard-deleted" in `app/Console/Commands/PruneReviewPacksCommand.php`
|
||||
- [X] T029 [US4] Add prune command schedule entry `tenantpilot:review-pack:prune` with `daily()` + `withoutOverlapping()` in `routes/console.php`
|
||||
- [X] T030 [P] [US4] Remove or hide `sla_due` event_type option from AlertRuleResource form dropdown (keep EVENT_SLA_DUE constant on model for backward compatibility) in `app/Filament/Resources/AlertRuleResource.php`
|
||||
- [X] T031 [US4] Create prune tests: past retention → status=expired + file deleted from disk; future retention → unaffected; --hard-delete past grace_days → rows removed from DB; --hard-delete within grace_days → rows kept; command output includes correct counts; AlertRule form no longer shows sla_due option in dropdown in `tests/Feature/ReviewPack/ReviewPackPruneTest.php`
|
||||
|
||||
**Checkpoint**: Retention automation verified — packs expire and files cleaned up automatically on schedule
|
||||
|
||||
@ -136,8 +136,8 @@ ## Phase 7: User Story 5 — Scheduled Scan Wiring (Priority: P3)
|
||||
|
||||
### Implementation for User Story 5
|
||||
|
||||
- [ ] T032 [US5] Verify existing Entra admin roles schedule closure in `routes/console.php` runs daily; add TODO comment for `tenantpilot:posture:dispatch` command (creation deferred per research.md — command infrastructure does not yet exist; FR-015 partially fulfilled)
|
||||
- [ ] T033 [US5] Create schedule assertion test: Entra admin roles dispatch appears with daily frequency; document posture:dispatch deferral as explicit test skip with rationale in `tests/Feature/ReviewPack/ReviewPackScheduleTest.php`
|
||||
- [X] T032 [US5] Verify existing Entra admin roles schedule closure in `routes/console.php` runs daily; add TODO comment for `tenantpilot:posture:dispatch` command (creation deferred per research.md — command infrastructure does not yet exist; FR-015 partially fulfilled)
|
||||
- [X] T033 [US5] Create schedule assertion test: Entra admin roles dispatch appears with daily frequency; document posture:dispatch deferral as explicit test skip with rationale in `tests/Feature/ReviewPack/ReviewPackScheduleTest.php`
|
||||
|
||||
**Checkpoint**: Scheduled scan wiring verified or documented as deferred
|
||||
|
||||
@ -147,9 +147,9 @@ ## Phase 8: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Code quality, formatting, and end-to-end validation
|
||||
|
||||
- [ ] T034 [P] Run Pint formatter on all new and modified files (`vendor/bin/sail bin pint --dirty --format agent`)
|
||||
- [ ] T035 Run full ReviewPack test suite and fix any failures (`vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/`)
|
||||
- [ ] T036 Validate quickstart.md scenarios end-to-end: create tenant with stored reports + findings, generate pack, verify ZIP contents (7 files), download via signed URL, expire pack, run prune command, confirm file deleted
|
||||
- [X] T034 [P] Run Pint formatter on all new and modified files (`vendor/bin/sail bin pint --dirty --format agent`)
|
||||
- [X] T035 Run full ReviewPack test suite and fix any failures (`vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/`)
|
||||
- [X] T036 Validate quickstart.md scenarios end-to-end: create tenant with stored reports + findings, generate pack, verify ZIP contents (7 files), download via signed URL, expire pack, run prune command, confirm file deleted
|
||||
|
||||
---
|
||||
|
||||
|
||||
174
tests/Feature/ReviewPack/ReviewPackDownloadTest.php
Normal file
174
tests/Feature/ReviewPack/ReviewPackDownloadTest.php
Normal file
@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ReviewPack;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
// ─── Helper ──────────────────────────────────────────────────
|
||||
|
||||
function createReadyPackWithFile(?array $packOverrides = []): array
|
||||
{
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$filePath = 'review-packs/'.$tenant->external_id.'/test.zip';
|
||||
Storage::disk('exports')->put($filePath, 'PK-fake-zip-content');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create(array_merge([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'file_path' => $filePath,
|
||||
'file_disk' => 'exports',
|
||||
'sha256' => hash('sha256', 'PK-fake-zip-content'),
|
||||
], $packOverrides));
|
||||
|
||||
return [$user, $tenant, $pack];
|
||||
}
|
||||
|
||||
// ─── Happy Path: Signed URL → 200 ───────────────────────────
|
||||
|
||||
it('downloads a ready pack via signed URL with correct headers', function (): void {
|
||||
[$user, $tenant, $pack] = createReadyPackWithFile();
|
||||
|
||||
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack);
|
||||
|
||||
$response = $this->actingAs($user)->get($signedUrl);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertHeader('X-Review-Pack-SHA256', $pack->sha256);
|
||||
$response->assertDownload();
|
||||
});
|
||||
|
||||
// ─── Expired Signature → 403 ────────────────────────────────
|
||||
|
||||
it('rejects requests with an expired signature', function (): void {
|
||||
[$user, $tenant, $pack] = createReadyPackWithFile();
|
||||
|
||||
// Generate a signed URL that expires immediately
|
||||
$signedUrl = URL::signedRoute(
|
||||
'admin.review-packs.download',
|
||||
['reviewPack' => $pack->getKey()],
|
||||
now()->subMinute(),
|
||||
);
|
||||
|
||||
$response = $this->actingAs($user)->get($signedUrl);
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
|
||||
// ─── Expired Pack → 404 ─────────────────────────────────────
|
||||
|
||||
it('returns 404 for an expired pack', function (): void {
|
||||
[$user, $tenant, $pack] = createReadyPackWithFile([
|
||||
'status' => ReviewPackStatus::Expired->value,
|
||||
]);
|
||||
|
||||
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack);
|
||||
|
||||
$response = $this->actingAs($user)->get($signedUrl);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ─── Non-Ready Pack → 404 ───────────────────────────────────
|
||||
|
||||
it('returns 404 for a queued pack', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$pack = ReviewPack::factory()->queued()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$signedUrl = URL::signedRoute(
|
||||
'admin.review-packs.download',
|
||||
['reviewPack' => $pack->getKey()],
|
||||
now()->addHour(),
|
||||
);
|
||||
|
||||
$response = $this->actingAs($user)->get($signedUrl);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ─── Non-Existent Pack → 404 ────────────────────────────────
|
||||
|
||||
it('returns 404 for a non-existent pack', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$signedUrl = URL::signedRoute(
|
||||
'admin.review-packs.download',
|
||||
['reviewPack' => 99999],
|
||||
now()->addHour(),
|
||||
);
|
||||
|
||||
$response = $this->actingAs($user)->get($signedUrl);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ─── Past Expiry Date → 404 ─────────────────────────────────
|
||||
|
||||
it('returns 404 when pack status is ready but expires_at is in the past', function (): void {
|
||||
[$user, $tenant, $pack] = createReadyPackWithFile([
|
||||
'expires_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$signedUrl = URL::signedRoute(
|
||||
'admin.review-packs.download',
|
||||
['reviewPack' => $pack->getKey()],
|
||||
now()->addHour(),
|
||||
);
|
||||
|
||||
$response = $this->actingAs($user)->get($signedUrl);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ─── Missing File on Disk → 404 ─────────────────────────────
|
||||
|
||||
it('returns 404 when file does not exist on disk', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'file_path' => 'review-packs/does-not-exist.zip',
|
||||
'file_disk' => 'exports',
|
||||
]);
|
||||
|
||||
$signedUrl = URL::signedRoute(
|
||||
'admin.review-packs.download',
|
||||
['reviewPack' => $pack->getKey()],
|
||||
now()->addHour(),
|
||||
);
|
||||
|
||||
$response = $this->actingAs($user)->get($signedUrl);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ─── Unsigned URL → 403 ─────────────────────────────────────
|
||||
|
||||
it('returns 403 for an unsigned URL', function (): void {
|
||||
[$user, $tenant, $pack] = createReadyPackWithFile();
|
||||
|
||||
$response = $this->actingAs($user)->get(
|
||||
route('admin.review-packs.download', ['reviewPack' => $pack->getKey()]),
|
||||
);
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
415
tests/Feature/ReviewPack/ReviewPackGenerationTest.php
Normal file
415
tests/Feature/ReviewPack/ReviewPackGenerationTest.php
Normal file
@ -0,0 +1,415 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\GenerateReviewPackJob;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Notifications\ReviewPackStatusNotification;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
// ─── Helper ──────────────────────────────────────────────────
|
||||
|
||||
function seedTenantWithData(Tenant $tenant): void
|
||||
{
|
||||
StoredReport::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
'payload' => [
|
||||
'posture_score' => 86,
|
||||
'required_count' => 14,
|
||||
'granted_count' => 12,
|
||||
'permissions' => [
|
||||
['key' => 'DeviceManagementConfiguration.ReadWrite.All', 'status' => 'granted'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
StoredReport::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||
'payload' => [
|
||||
'roles' => [
|
||||
[
|
||||
'displayName' => 'Global Administrator',
|
||||
'userPrincipalName' => 'admin@contoso.com',
|
||||
'role_template_id' => '62e90394-69f5-4237-9190-012177145e10',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Finding::factory()
|
||||
->count(3)
|
||||
->create(['tenant_id' => (int) $tenant->getKey()]);
|
||||
}
|
||||
|
||||
// ─── Happy Path ──────────────────────────────────────────────
|
||||
|
||||
it('generates a review pack end-to-end (happy path)', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
seedTenantWithData($tenant);
|
||||
Notification::fake();
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
$pack = $service->generate($tenant, $user, [
|
||||
'include_pii' => true,
|
||||
'include_operations' => true,
|
||||
]);
|
||||
|
||||
expect($pack)->toBeInstanceOf(ReviewPack::class);
|
||||
expect($pack->status)->toBe(ReviewPackStatus::Queued->value);
|
||||
|
||||
// Dispatch the queued job synchronously
|
||||
$job = new GenerateReviewPackJob(
|
||||
reviewPackId: (int) $pack->getKey(),
|
||||
operationRunId: (int) $pack->operation_run_id,
|
||||
);
|
||||
$job->handle();
|
||||
|
||||
$pack->refresh();
|
||||
|
||||
expect($pack->status)->toBe(ReviewPackStatus::Ready->value);
|
||||
expect($pack->sha256)->toBeString()->not->toBeEmpty();
|
||||
expect($pack->file_size)->toBeGreaterThan(0);
|
||||
expect($pack->file_path)->toBeString()->not->toBeEmpty();
|
||||
expect($pack->file_disk)->toBe('exports');
|
||||
expect($pack->generated_at)->not->toBeNull();
|
||||
expect($pack->expires_at)->not->toBeNull();
|
||||
expect($pack->fingerprint)->toBeString()->not->toBeEmpty();
|
||||
expect($pack->summary)->toBeArray();
|
||||
expect($pack->summary['finding_count'])->toBe(3);
|
||||
expect($pack->summary['report_count'])->toBe(2);
|
||||
|
||||
// File exists on disk
|
||||
Storage::disk('exports')->assertExists($pack->file_path);
|
||||
|
||||
// OperationRun completed
|
||||
$opRun = OperationRun::query()->find($pack->operation_run_id);
|
||||
expect($opRun->status)->toBe(OperationRunStatus::Completed->value);
|
||||
expect($opRun->outcome)->toBe(OperationRunOutcome::Succeeded->value);
|
||||
|
||||
// Notification sent
|
||||
Notification::assertSentTo($user, ReviewPackStatusNotification::class);
|
||||
});
|
||||
|
||||
// ─── Failure Path ──────────────────────────────────────────────
|
||||
|
||||
it('marks pack as failed when generation throws an exception', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
Notification::fake();
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
$pack = $service->generate($tenant, $user);
|
||||
|
||||
// Replace the exports disk with a mock that throws on put()
|
||||
$fakeDisk = Mockery::mock(\Illuminate\Contracts\Filesystem\Filesystem::class);
|
||||
$fakeDisk->shouldReceive('put')
|
||||
->andThrow(new \RuntimeException('Simulated storage failure'));
|
||||
|
||||
Storage::shouldReceive('disk')
|
||||
->with('exports')
|
||||
->andReturn($fakeDisk);
|
||||
|
||||
$job = new GenerateReviewPackJob(
|
||||
reviewPackId: (int) $pack->getKey(),
|
||||
operationRunId: (int) $pack->operation_run_id,
|
||||
);
|
||||
|
||||
try {
|
||||
$job->handle();
|
||||
} catch (\RuntimeException) {
|
||||
// Expected — the job re-throws after marking failed
|
||||
}
|
||||
|
||||
$pack->refresh();
|
||||
|
||||
expect($pack->status)->toBe(ReviewPackStatus::Failed->value);
|
||||
|
||||
$opRun = OperationRun::query()->find($pack->operation_run_id);
|
||||
expect($opRun->status)->toBe(OperationRunStatus::Completed->value);
|
||||
expect($opRun->outcome)->toBe(OperationRunOutcome::Failed->value);
|
||||
expect($opRun->context['reason_code'])->toBe('generation_error');
|
||||
|
||||
Notification::assertSentTo($user, ReviewPackStatusNotification::class, function ($notification) {
|
||||
return $notification->status === 'failed';
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Empty Reports ──────────────────────────────────────────────
|
||||
|
||||
it('succeeds with empty reports and findings', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
Notification::fake();
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
$pack = $service->generate($tenant, $user);
|
||||
|
||||
$job = new GenerateReviewPackJob(
|
||||
reviewPackId: (int) $pack->getKey(),
|
||||
operationRunId: (int) $pack->operation_run_id,
|
||||
);
|
||||
$job->handle();
|
||||
|
||||
$pack->refresh();
|
||||
|
||||
expect($pack->status)->toBe(ReviewPackStatus::Ready->value);
|
||||
expect($pack->summary['finding_count'])->toBe(0);
|
||||
expect($pack->summary['report_count'])->toBe(0);
|
||||
Storage::disk('exports')->assertExists($pack->file_path);
|
||||
});
|
||||
|
||||
// ─── PII Redaction ──────────────────────────────────────────────
|
||||
|
||||
it('redacts PII when include_pii is false', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
seedTenantWithData($tenant);
|
||||
Notification::fake();
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
$pack = $service->generate($tenant, $user, ['include_pii' => false]);
|
||||
|
||||
$job = new GenerateReviewPackJob(
|
||||
reviewPackId: (int) $pack->getKey(),
|
||||
operationRunId: (int) $pack->operation_run_id,
|
||||
);
|
||||
$job->handle();
|
||||
|
||||
$pack->refresh();
|
||||
expect($pack->status)->toBe(ReviewPackStatus::Ready->value);
|
||||
|
||||
// Read the generated ZIP to verify PII redaction
|
||||
$zipContent = Storage::disk('exports')->get($pack->file_path);
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'test-zip-');
|
||||
file_put_contents($tempFile, $zipContent);
|
||||
|
||||
$zip = new ZipArchive;
|
||||
$zip->open($tempFile);
|
||||
|
||||
// Check metadata.json redacts tenant name
|
||||
$metadata = json_decode($zip->getFromName('metadata.json'), true);
|
||||
expect($metadata['tenant_name'])->toBe('[REDACTED]');
|
||||
expect($metadata['options']['include_pii'])->toBeFalse();
|
||||
|
||||
// Check findings.csv redacts title in rows
|
||||
$findingsCsv = $zip->getFromName('findings.csv');
|
||||
expect($findingsCsv)->toContain('[REDACTED]');
|
||||
|
||||
// Check entra_admin_roles.json redacts displayName
|
||||
$entraReport = json_decode($zip->getFromName('reports/entra_admin_roles.json'), true);
|
||||
|
||||
if (! empty($entraReport) && isset($entraReport['roles'])) {
|
||||
foreach ($entraReport['roles'] as $role) {
|
||||
if (isset($role['displayName'])) {
|
||||
expect($role['displayName'])->toBe('[REDACTED]');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
unlink($tempFile);
|
||||
});
|
||||
|
||||
// ─── ZIP Contents ──────────────────────────────────────────────
|
||||
|
||||
it('produces a ZIP with exactly 7 files in alphabetical order', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
seedTenantWithData($tenant);
|
||||
Notification::fake();
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
$pack = $service->generate($tenant, $user, [
|
||||
'include_pii' => true,
|
||||
'include_operations' => true,
|
||||
]);
|
||||
|
||||
$job = new GenerateReviewPackJob(
|
||||
reviewPackId: (int) $pack->getKey(),
|
||||
operationRunId: (int) $pack->operation_run_id,
|
||||
);
|
||||
$job->handle();
|
||||
|
||||
$pack->refresh();
|
||||
|
||||
$zipContent = Storage::disk('exports')->get($pack->file_path);
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'test-zip-');
|
||||
file_put_contents($tempFile, $zipContent);
|
||||
|
||||
$zip = new ZipArchive;
|
||||
$zip->open($tempFile);
|
||||
|
||||
$files = [];
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$files[] = $zip->getNameIndex($i);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
unlink($tempFile);
|
||||
|
||||
$expectedFiles = [
|
||||
'findings.csv',
|
||||
'hardening.json',
|
||||
'metadata.json',
|
||||
'operations.csv',
|
||||
'reports/entra_admin_roles.json',
|
||||
'reports/permission_posture.json',
|
||||
'summary.json',
|
||||
];
|
||||
|
||||
expect($files)->toHaveCount(7);
|
||||
expect($files)->toEqual($expectedFiles);
|
||||
});
|
||||
|
||||
// ─── Service dispatches job ──────────────────────────────────
|
||||
|
||||
it('dispatches GenerateReviewPackJob when generate is called', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
$pack = $service->generate($tenant, $user);
|
||||
|
||||
Queue::assertPushed(GenerateReviewPackJob::class, function ($job) use ($pack) {
|
||||
return $job->reviewPackId === (int) $pack->getKey();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── OperationRun Type ──────────────────────────────────────────
|
||||
|
||||
it('creates an OperationRun of type review_pack_generate', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
$pack = $service->generate($tenant, $user);
|
||||
|
||||
$opRun = OperationRun::query()->find($pack->operation_run_id);
|
||||
expect($opRun)->not->toBeNull();
|
||||
expect($opRun->type)->toBe(OperationRunType::ReviewPackGenerate->value);
|
||||
expect($opRun->status)->toBe(OperationRunStatus::Queued->value);
|
||||
});
|
||||
|
||||
// ─── Fingerprint Determinism ──────────────────────────────────
|
||||
|
||||
it('computes the same fingerprint for identical inputs', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
seedTenantWithData($tenant);
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
|
||||
$options = ['include_pii' => true, 'include_operations' => true];
|
||||
$fp1 = $service->computeFingerprint($tenant, $options);
|
||||
$fp2 = $service->computeFingerprint($tenant, $options);
|
||||
|
||||
expect($fp1)->toBe($fp2);
|
||||
expect(strlen($fp1))->toBe(64); // SHA-256 hex length
|
||||
});
|
||||
|
||||
// ─── Different options produce different fingerprints ─────────
|
||||
|
||||
it('computes different fingerprints when options differ', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
seedTenantWithData($tenant);
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
|
||||
$fp1 = $service->computeFingerprint($tenant, ['include_pii' => true, 'include_operations' => true]);
|
||||
$fp2 = $service->computeFingerprint($tenant, ['include_pii' => false, 'include_operations' => true]);
|
||||
|
||||
expect($fp1)->not->toBe($fp2);
|
||||
});
|
||||
|
||||
// ─── Fingerprint Dedupe (T025) ────────────────────────────────
|
||||
|
||||
it('returns existing ready pack when fingerprint matches (dedupe)', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
seedTenantWithData($tenant);
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
|
||||
$options = ['include_pii' => true, 'include_operations' => true];
|
||||
|
||||
// Compute the fingerprint that the service would compute with normalized options
|
||||
$fingerprint = $service->computeFingerprint($tenant, $options);
|
||||
|
||||
$pack1 = $service->generate($tenant, $user, $options);
|
||||
|
||||
// Manually set the pack to ready with the correct fingerprint so dedupe triggers
|
||||
$pack1->update([
|
||||
'status' => ReviewPackStatus::Ready->value,
|
||||
'fingerprint' => $fingerprint,
|
||||
'expires_at' => now()->addDays(90),
|
||||
]);
|
||||
|
||||
// Second call with same options should return the existing pack
|
||||
$pack2 = $service->generate($tenant, $user, $options);
|
||||
|
||||
expect($pack2->getKey())->toBe($pack1->getKey());
|
||||
expect(ReviewPack::query()->where('tenant_id', (int) $tenant->getKey())->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('allows new generation when existing pack with same fingerprint is expired', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
seedTenantWithData($tenant);
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
|
||||
$options = ['include_pii' => true, 'include_operations' => true];
|
||||
|
||||
// Create an expired pack with a matching fingerprint
|
||||
$fingerprint = $service->computeFingerprint($tenant, ['include_pii' => true, 'include_operations' => true]);
|
||||
|
||||
ReviewPack::factory()->expired()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'fingerprint' => $fingerprint,
|
||||
]);
|
||||
|
||||
// Should create a new pack since existing is expired
|
||||
$newPack = $service->generate($tenant, $user, $options);
|
||||
|
||||
expect(ReviewPack::query()->where('tenant_id', (int) $tenant->getKey())->count())->toBe(2);
|
||||
expect($newPack->status)->toBe(ReviewPackStatus::Queued->value);
|
||||
});
|
||||
163
tests/Feature/ReviewPack/ReviewPackPruneTest.php
Normal file
163
tests/Feature/ReviewPack/ReviewPackPruneTest.php
Normal file
@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\AlertRuleResource;
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\ReviewPack;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
it('expires ready packs past retention and deletes their files', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$filePath = 'review-packs/test-expired.zip';
|
||||
Storage::disk('exports')->put($filePath, 'fake content');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'initiated_by_user_id' => $user->id,
|
||||
'file_path' => $filePath,
|
||||
'expires_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$this->artisan('tenantpilot:review-pack:prune')
|
||||
->assertSuccessful();
|
||||
|
||||
$pack->refresh();
|
||||
expect($pack->status)->toBe(ReviewPack::STATUS_EXPIRED)
|
||||
->and(Storage::disk('exports')->exists($filePath))->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not expire ready packs with future retention', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$filePath = 'review-packs/test-future.zip';
|
||||
Storage::disk('exports')->put($filePath, 'fake content');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'initiated_by_user_id' => $user->id,
|
||||
'file_path' => $filePath,
|
||||
'expires_at' => now()->addDays(30),
|
||||
]);
|
||||
|
||||
$this->artisan('tenantpilot:review-pack:prune')
|
||||
->assertSuccessful();
|
||||
|
||||
$pack->refresh();
|
||||
expect($pack->status)->toBe(ReviewPack::STATUS_READY)
|
||||
->and(Storage::disk('exports')->exists($filePath))->toBeTrue();
|
||||
});
|
||||
|
||||
it('hard-deletes expired packs past grace period', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$graceDays = config('tenantpilot.review_pack.hard_delete_grace_days', 30);
|
||||
|
||||
$pack = ReviewPack::factory()->expired()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'initiated_by_user_id' => $user->id,
|
||||
'updated_at' => now()->subDays($graceDays + 5),
|
||||
]);
|
||||
|
||||
$this->artisan('tenantpilot:review-pack:prune --hard-delete')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(ReviewPack::query()->whereKey($pack->getKey())->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('keeps expired packs within grace period when hard-deleting', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$pack = ReviewPack::factory()->expired()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'initiated_by_user_id' => $user->id,
|
||||
'updated_at' => now()->subDays(5),
|
||||
]);
|
||||
|
||||
$this->artisan('tenantpilot:review-pack:prune --hard-delete')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(ReviewPack::query()->whereKey($pack->getKey())->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('outputs correct counts', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
// 2 packs past retention → expired
|
||||
ReviewPack::factory()->count(2)->ready()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'initiated_by_user_id' => $user->id,
|
||||
'expires_at' => now()->subDays(3),
|
||||
]);
|
||||
|
||||
$this->artisan('tenantpilot:review-pack:prune')
|
||||
->expectsOutputToContain('2 pack(s) expired, 0 pack(s) hard-deleted')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('outputs hard-delete counts when option is passed', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$graceDays = config('tenantpilot.review_pack.hard_delete_grace_days', 30);
|
||||
|
||||
// 1 pack past retention → expired
|
||||
ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'initiated_by_user_id' => $user->id,
|
||||
'expires_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
// 1 expired pack past grace → hard-deleted
|
||||
ReviewPack::factory()->expired()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'initiated_by_user_id' => $user->id,
|
||||
'updated_at' => now()->subDays($graceDays + 10),
|
||||
]);
|
||||
|
||||
$this->artisan('tenantpilot:review-pack:prune --hard-delete')
|
||||
->expectsOutputToContain('1 pack(s) expired, 1 pack(s) hard-deleted')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('does not hard-delete without the flag', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$graceDays = config('tenantpilot.review_pack.hard_delete_grace_days', 30);
|
||||
|
||||
$pack = ReviewPack::factory()->expired()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'initiated_by_user_id' => $user->id,
|
||||
'updated_at' => now()->subDays($graceDays + 10),
|
||||
]);
|
||||
|
||||
$this->artisan('tenantpilot:review-pack:prune')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(ReviewPack::query()->whereKey($pack->getKey())->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('AlertRule form no longer shows sla_due option', function (): void {
|
||||
$options = AlertRuleResource::eventTypeOptions();
|
||||
|
||||
expect($options)->not->toHaveKey(AlertRule::EVENT_SLA_DUE)
|
||||
->and($options)->toHaveKey(AlertRule::EVENT_HIGH_DRIFT)
|
||||
->and($options)->toHaveKey(AlertRule::EVENT_COMPARE_FAILED)
|
||||
->and($options)->toHaveKey(AlertRule::EVENT_PERMISSION_MISSING)
|
||||
->and($options)->toHaveKey(AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH);
|
||||
});
|
||||
214
tests/Feature/ReviewPack/ReviewPackRbacTest.php
Normal file
214
tests/Feature/ReviewPack/ReviewPackRbacTest.php
Normal file
@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
// ─── Non-Member Access ───────────────────────────────────────
|
||||
|
||||
it('returns 404 for non-member on list page', function (): void {
|
||||
$targetTenant = Tenant::factory()->create();
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
|
||||
[$user] = createUserWithTenant($otherTenant, role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(ReviewPackResource::getUrl('index', tenant: $targetTenant))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 404 for non-member on view page', function (): void {
|
||||
$targetTenant = Tenant::factory()->create();
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
|
||||
[$user] = createUserWithTenant($otherTenant, role: 'owner');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $targetTenant->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $targetTenant))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 404 for non-member on download route', function (): void {
|
||||
$targetTenant = Tenant::factory()->create();
|
||||
$otherTenant = Tenant::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([
|
||||
'tenant_id' => (int) $targetTenant->getKey(),
|
||||
'file_path' => $filePath,
|
||||
'file_disk' => 'exports',
|
||||
]);
|
||||
|
||||
// Note: download route uses signed middleware, not tenant-scoped RBAC.
|
||||
// Any user with a valid signature can download. This is by design.
|
||||
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack);
|
||||
|
||||
$this->actingAs($user)->get($signedUrl)->assertOk();
|
||||
});
|
||||
|
||||
// ─── REVIEW_PACK_VIEW Member ────────────────────────────────
|
||||
|
||||
it('allows readonly member to access list page', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(ReviewPackResource::getUrl('index', tenant: $tenant))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('allows readonly member to access view page', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_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))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('allows readonly member to download via signed URL', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
|
||||
$filePath = 'review-packs/readonly-test.zip';
|
||||
Storage::disk('exports')->put($filePath, 'PK-fake');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'file_path' => $filePath,
|
||||
'file_disk' => 'exports',
|
||||
]);
|
||||
|
||||
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack);
|
||||
|
||||
$this->actingAs($user)->get($signedUrl)->assertOk();
|
||||
});
|
||||
|
||||
it('disables generate action for readonly member', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListReviewPacks::class)
|
||||
->assertActionVisible('generate_pack')
|
||||
->assertActionDisabled('generate_pack')
|
||||
->assertActionExists('generate_pack', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
|
||||
});
|
||||
|
||||
// ─── REVIEW_PACK_MANAGE Member ──────────────────────────────
|
||||
|
||||
it('allows owner to generate a review pack', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$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 = Tenant::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([
|
||||
'tenant_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 = Tenant::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([
|
||||
'tenant_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 = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_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();
|
||||
});
|
||||
241
tests/Feature/ReviewPack/ReviewPackResourceTest.php
Normal file
241
tests/Feature/ReviewPack/ReviewPackResourceTest.php
Normal file
@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks;
|
||||
use App\Filament\Resources\ReviewPackResource\Pages\ViewReviewPack;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
// ─── List Page ───────────────────────────────────────────────
|
||||
|
||||
it('renders the list page for an authorized user', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(ReviewPackResource::getUrl('index', tenant: $tenant))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('shows review packs belonging to the active tenant', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $otherTenant->getKey(),
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListReviewPacks::class)
|
||||
->assertCanSeeTableRecords([$pack]);
|
||||
});
|
||||
|
||||
it('displays the empty state when no packs exist', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListReviewPacks::class)
|
||||
->assertSee('No review packs yet');
|
||||
});
|
||||
|
||||
// ─── List Page Header Action ─────────────────────────────────
|
||||
|
||||
it('shows the generate_pack header action for a MANAGE user', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListReviewPacks::class)
|
||||
->assertActionVisible('generate_pack');
|
||||
});
|
||||
|
||||
it('disables the generate_pack action for a readonly user', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListReviewPacks::class)
|
||||
->assertActionVisible('generate_pack')
|
||||
->assertActionDisabled('generate_pack')
|
||||
->assertActionExists('generate_pack', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
|
||||
});
|
||||
|
||||
// ─── Table Row Actions ───────────────────────────────────────
|
||||
|
||||
it('shows the download action for a ready pack', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$filePath = 'review-packs/test.zip';
|
||||
Storage::disk('exports')->put($filePath, 'PK-fake');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'file_path' => $filePath,
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListReviewPacks::class)
|
||||
->assertTableActionVisible('download', $pack);
|
||||
});
|
||||
|
||||
it('shows the expire action for a ready pack with confirmation', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$filePath = 'review-packs/expire-test.zip';
|
||||
Storage::disk('exports')->put($filePath, 'PK-fake');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_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);
|
||||
Storage::disk('exports')->assertMissing($filePath);
|
||||
});
|
||||
|
||||
// ─── View Page ───────────────────────────────────────────────
|
||||
|
||||
it('renders the view page for a ready pack', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'summary' => [
|
||||
'finding_count' => 5,
|
||||
'report_count' => 2,
|
||||
'operation_count' => 12,
|
||||
'data_freshness' => [
|
||||
'permission_posture' => now()->toIso8601String(),
|
||||
'entra_admin_roles' => now()->toIso8601String(),
|
||||
'findings' => now()->toIso8601String(),
|
||||
'hardening' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
'options' => ['include_pii' => true, 'include_operations' => true],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('shows download header action on view page for a ready pack', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_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(ViewReviewPack::class, ['record' => $pack->getKey()])
|
||||
->assertActionVisible('download');
|
||||
});
|
||||
|
||||
it('shows regenerate header action on view page', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_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(ViewReviewPack::class, ['record' => $pack->getKey()])
|
||||
->assertActionVisible('regenerate');
|
||||
});
|
||||
|
||||
// ─── Non-Member Access ───────────────────────────────────────
|
||||
|
||||
it('returns 404 for non-members on list page', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
|
||||
[$user] = createUserWithTenant($otherTenant, role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(ReviewPackResource::getUrl('index', tenant: $tenant))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 404 for non-members on view page', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
|
||||
[$user] = createUserWithTenant($otherTenant, role: 'owner');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant))
|
||||
->assertNotFound();
|
||||
});
|
||||
41
tests/Feature/ReviewPack/ReviewPackScheduleTest.php
Normal file
41
tests/Feature/ReviewPack/ReviewPackScheduleTest.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
|
||||
it('schedules review pack prune command daily without overlapping', function (): void {
|
||||
/** @var Schedule $schedule */
|
||||
$schedule = app(Schedule::class);
|
||||
|
||||
$event = collect($schedule->events())
|
||||
->first(fn ($event) => str_contains($event->command ?? '', 'tenantpilot:review-pack:prune'));
|
||||
|
||||
expect($event)->not->toBeNull('review-pack:prune should be scheduled');
|
||||
expect($event->withoutOverlapping)->toBeTrue();
|
||||
});
|
||||
|
||||
it('schedules entra admin roles scan daily without overlapping', function (): void {
|
||||
/** @var Schedule $schedule */
|
||||
$schedule = app(Schedule::class);
|
||||
|
||||
$event = collect($schedule->events())
|
||||
->first(fn ($event) => ($event->description ?? null) === 'entra-admin-roles:scan');
|
||||
|
||||
expect($event)->not->toBeNull('entra-admin-roles:scan should be scheduled');
|
||||
expect($event->withoutOverlapping)->toBeTrue();
|
||||
});
|
||||
|
||||
it('documents posture:dispatch deferral', function (): void {
|
||||
// FR-015: tenantpilot:posture:dispatch command infrastructure does not yet exist.
|
||||
// The schedule entry will be added once the command is created.
|
||||
// See specs/109-review-pack-export/research.md §7 for rationale.
|
||||
|
||||
/** @var Schedule $schedule */
|
||||
$schedule = app(Schedule::class);
|
||||
|
||||
$event = collect($schedule->events())
|
||||
->first(fn ($event) => str_contains($event->command ?? '', 'posture:dispatch'));
|
||||
|
||||
expect($event)->toBeNull('posture:dispatch not yet implemented — deferred per FR-015');
|
||||
})->skip('posture:dispatch command deferred — see specs/109 research.md §7');
|
||||
155
tests/Feature/ReviewPack/ReviewPackWidgetTest.php
Normal file
155
tests/Feature/ReviewPack/ReviewPackWidgetTest.php
Normal file
@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Widgets\Tenant\TenantReviewPackCard;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
// ─── No Pack State ───────────────────────────────────────────
|
||||
|
||||
it('shows the generate CTA when no pack exists', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantReviewPackCard::class, ['record' => $tenant])
|
||||
->assertSee('No review pack generated yet')
|
||||
->assertSee('Generate');
|
||||
});
|
||||
|
||||
// ─── Ready State ─────────────────────────────────────────────
|
||||
|
||||
it('shows download and generate buttons when a ready pack exists', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$filePath = 'review-packs/widget-test.zip';
|
||||
Storage::disk('exports')->put($filePath, 'PK-test');
|
||||
|
||||
ReviewPack::factory()->ready()->create([
|
||||
'tenant_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(TenantReviewPackCard::class, ['record' => $tenant])
|
||||
->assertSee('Download')
|
||||
->assertSee('Generate new');
|
||||
});
|
||||
|
||||
// ─── Generating State ────────────────────────────────────────
|
||||
|
||||
it('shows in-progress message for a generating pack', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
ReviewPack::factory()->generating()->create([
|
||||
'tenant_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(TenantReviewPackCard::class, ['record' => $tenant])
|
||||
->assertSee('Generation in progress');
|
||||
});
|
||||
|
||||
// ─── Queued State ────────────────────────────────────────────
|
||||
|
||||
it('shows in-progress message for a queued pack', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
ReviewPack::factory()->queued()->create([
|
||||
'tenant_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(TenantReviewPackCard::class, ['record' => $tenant])
|
||||
->assertSee('Generation in progress');
|
||||
});
|
||||
|
||||
// ─── Failed State ────────────────────────────────────────────
|
||||
|
||||
it('shows retry button for a failed pack', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
ReviewPack::factory()->failed()->create([
|
||||
'tenant_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(TenantReviewPackCard::class, ['record' => $tenant])
|
||||
->assertSee('Retry');
|
||||
});
|
||||
|
||||
// ─── Expired State ───────────────────────────────────────────
|
||||
|
||||
it('shows generate action for an expired pack', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
ReviewPack::factory()->expired()->create([
|
||||
'tenant_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(TenantReviewPackCard::class, ['record' => $tenant])
|
||||
->assertSee('Generate new');
|
||||
});
|
||||
|
||||
// ─── Generate Pack Livewire Action ──────────────────────────
|
||||
|
||||
it('can trigger generatePack Livewire action', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantReviewPackCard::class, ['record' => $tenant])
|
||||
->call('generatePack', true, true)
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(ReviewPack::query()->where('tenant_id', (int) $tenant->getKey())->count())->toBe(1);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user