Added jobs, controllers, and PDF generation logic for management report runtime as defined in Spec 379. Includes artifact migrations, payload builders, and testing coverage. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #450
696 lines
27 KiB
PHP
696 lines
27 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Filament\Resources\ReviewPackResource\Pages\ViewReviewPack;
|
|
use App\Jobs\GenerateManagementReportPdfJob;
|
|
use App\Jobs\GenerateReviewPackJob;
|
|
use App\Models\AuditLog;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ReviewPack;
|
|
use App\Models\StoredReport;
|
|
use App\Models\User;
|
|
use App\Services\Graph\GraphClientInterface;
|
|
use App\Services\ReviewPacks\ManagementReportPdfService;
|
|
use App\Support\Audit\AuditActionId;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
use App\Support\ReviewPacks\ManagementReportPdfPayloadBuilder;
|
|
use App\Support\ReviewPacks\ReportProfileRegistry;
|
|
use Filament\Actions\Action;
|
|
use Illuminate\Http\Client\Request;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Livewire\Livewire;
|
|
use Tests\Support\FailHardGraphClient;
|
|
|
|
beforeEach(function (): void {
|
|
Storage::fake('exports');
|
|
app()->instance(GraphClientInterface::class, new FailHardGraphClient);
|
|
});
|
|
|
|
function spec379ConfigurePdfRenderer(bool $runtimeValidated = true, array $overrides = []): void
|
|
{
|
|
config([
|
|
'tenantpilot.pdf_renderer' => array_merge([
|
|
'enabled' => true,
|
|
'runtime_validated' => $runtimeValidated,
|
|
'driver' => 'gotenberg',
|
|
'base_url' => 'http://gotenberg.test',
|
|
'health_path' => '/health',
|
|
'html_route' => '/forms/chromium/convert/html',
|
|
'timeout_seconds' => 30,
|
|
'connect_timeout_seconds' => 5,
|
|
'max_html_bytes' => 1024 * 1024,
|
|
'max_asset_bytes' => 512 * 1024,
|
|
'max_output_bytes' => 2 * 1024 * 1024,
|
|
'correlation_header' => 'Gotenberg-Trace',
|
|
'output_filename' => 'tenantpilot-management-report',
|
|
], $overrides),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $files
|
|
*/
|
|
function spec379ZipContents(array $files = []): string
|
|
{
|
|
$tempFile = tempnam(sys_get_temp_dir(), 'spec379-review-pack-');
|
|
|
|
if ($tempFile === false) {
|
|
throw new RuntimeException('Failed to allocate Spec379 archive.');
|
|
}
|
|
|
|
try {
|
|
$zip = new ZipArchive;
|
|
$result = $zip->open($tempFile, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
|
|
|
if ($result !== true) {
|
|
throw new RuntimeException("Failed to create Spec379 archive: {$result}");
|
|
}
|
|
|
|
foreach (array_replace([
|
|
'metadata.json' => json_encode(['fixture' => 'spec379'], JSON_THROW_ON_ERROR),
|
|
'executive-summary.md' => 'Spec379 current review pack.',
|
|
], $files) as $filename => $contents) {
|
|
$zip->addFromString($filename, $contents);
|
|
}
|
|
|
|
$zip->close();
|
|
|
|
$contents = file_get_contents($tempFile);
|
|
|
|
if (! is_string($contents) || $contents === '') {
|
|
throw new RuntimeException('Spec379 archive is empty.');
|
|
}
|
|
|
|
return $contents;
|
|
} finally {
|
|
if (file_exists($tempFile)) {
|
|
unlink($tempFile);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $summaryOverrides
|
|
* @return array{0: \App\Models\User, 1: \App\Models\ManagedEnvironment, 2: \App\Models\EnvironmentReview, 3: ReviewPack}
|
|
*/
|
|
function spec379CurrentReadyPack(array $summaryOverrides = []): array
|
|
{
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner', clearCapabilityCaches: true);
|
|
$snapshot = seedEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
|
|
$review = composeEnvironmentReviewForTest($tenant, $user, $snapshot);
|
|
$review->forceFill([
|
|
'status' => 'published',
|
|
'published_at' => now(),
|
|
'published_by_user_id' => (int) $user->getKey(),
|
|
])->save();
|
|
$review = markEnvironmentReviewCustomerSafeReady($review);
|
|
|
|
$zipContents = spec379ZipContents();
|
|
$filePath = sprintf('review-packs/%s/spec379-current.zip', $tenant->external_id);
|
|
Storage::disk('exports')->put($filePath, $zipContents);
|
|
|
|
$summary = array_replace_recursive([
|
|
'governance_package' => [
|
|
'executive_summary' => 'The released review is ready for a customer management handoff.',
|
|
],
|
|
'decision_summary' => [
|
|
'entries' => [
|
|
[
|
|
'title' => 'Proceed with governed handoff',
|
|
'summary' => 'Customer-safe evidence is ready.',
|
|
'rationale' => 'Current Review Pack is complete.',
|
|
],
|
|
],
|
|
],
|
|
'top_findings' => [
|
|
[
|
|
'title' => 'Privileged access review',
|
|
'severity' => 'medium',
|
|
'summary' => 'Review privileged role assignment cadence.',
|
|
],
|
|
],
|
|
'risk_acceptance' => [
|
|
[
|
|
'title' => 'Known exception',
|
|
'summary' => 'Accepted until the next operating review.',
|
|
],
|
|
],
|
|
'recommended_next_actions' => [
|
|
[
|
|
'title' => 'Schedule review',
|
|
'summary' => 'Review the management report with customer stakeholders.',
|
|
],
|
|
],
|
|
'control_interpretation' => [
|
|
'non_certification_disclosure' => 'TenantPilot summarizes available service-delivery evidence for governance review. This report is not a certification, legal attestation, audit opinion, or compliance guarantee.',
|
|
],
|
|
], $summaryOverrides);
|
|
|
|
$pack = ReviewPack::factory()->ready()->create([
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'environment_review_id' => (int) $review->getKey(),
|
|
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
'initiated_by_user_id' => (int) $user->getKey(),
|
|
'options' => [
|
|
'include_pii' => false,
|
|
'include_operations' => true,
|
|
],
|
|
'summary' => $summary,
|
|
'file_path' => $filePath,
|
|
'file_disk' => 'exports',
|
|
'file_size' => strlen($zipContents),
|
|
'sha256' => hash('sha256', $zipContents),
|
|
'expires_at' => now()->addDay(),
|
|
]);
|
|
|
|
$review->forceFill([
|
|
'current_export_review_pack_id' => (int) $pack->getKey(),
|
|
])->save();
|
|
|
|
return [$user, $tenant, $review->fresh(['sections', 'evidenceSnapshot', 'currentExportReviewPack']), $pack->fresh(['tenant', 'environmentReview'])];
|
|
}
|
|
|
|
function spec379ReadyManagementPdf(ReviewPack $pack): StoredReport
|
|
{
|
|
$pdfBytes = '%PDF-1.7 Spec379 ready management report';
|
|
$filePath = sprintf('management-reports/%s/ready-%d.pdf', $pack->tenant->external_id, (int) $pack->getKey());
|
|
|
|
Storage::disk('exports')->put($filePath, $pdfBytes);
|
|
|
|
return StoredReport::factory()->managementReportPdf([
|
|
'title' => 'TenantPilot Management Report',
|
|
])->create([
|
|
'workspace_id' => (int) $pack->workspace_id,
|
|
'managed_environment_id' => (int) $pack->managed_environment_id,
|
|
'source_environment_review_id' => (int) $pack->environment_review_id,
|
|
'source_review_pack_id' => (int) $pack->getKey(),
|
|
'profile' => ReportProfileRegistry::CUSTOMER_EXECUTIVE,
|
|
'file_disk' => 'exports',
|
|
'file_path' => $filePath,
|
|
'file_size' => strlen($pdfBytes),
|
|
'sha256' => hash('sha256', $pdfBytes),
|
|
'generated_at' => now(),
|
|
]);
|
|
}
|
|
|
|
it('Spec379 blocks generation until the PDF runtime is validated', function (): void {
|
|
spec379ConfigurePdfRenderer(runtimeValidated: false);
|
|
[$user, $tenant, , $pack] = spec379CurrentReadyPack();
|
|
Queue::fake();
|
|
$runCount = OperationRun::query()->count();
|
|
$reportCount = StoredReport::query()
|
|
->where('report_type', StoredReport::REPORT_TYPE_MANAGEMENT_REPORT_PDF)
|
|
->count();
|
|
|
|
$service = app(ManagementReportPdfService::class);
|
|
$decision = $service->generationDecision($pack);
|
|
$result = $service->startGeneration($pack, $user);
|
|
|
|
expect($decision['is_blocked'])->toBeTrue()
|
|
->and($decision['reason_code'])->toBe('runtime_validation_missing')
|
|
->and($result['mode'])->toBe('blocked')
|
|
->and(OperationRun::query()->count())->toBe($runCount)
|
|
->and(StoredReport::query()->where('report_type', StoredReport::REPORT_TYPE_MANAGEMENT_REPORT_PDF)->count())->toBe($reportCount);
|
|
|
|
Queue::assertNothingPushed();
|
|
|
|
$audit = AuditLog::query()
|
|
->where('action', AuditActionId::ManagementReportPdfGenerationBlocked->value)
|
|
->latest('id')
|
|
->first();
|
|
|
|
expect($audit)->not->toBeNull()
|
|
->and($audit?->workspace_id)->toBe((int) $tenant->workspace_id)
|
|
->and($audit?->resource_type)->toBe('stored_report')
|
|
->and(data_get($audit?->metadata, 'decision.reason_code'))->toBe('runtime_validation_missing');
|
|
});
|
|
|
|
it('Spec379 queues generation, renders through Gotenberg, stores a private PDF, and records operation proof', function (): void {
|
|
spec379ConfigurePdfRenderer();
|
|
[$user, $tenant, $review, $pack] = spec379CurrentReadyPack([
|
|
'top_findings' => [
|
|
[
|
|
'title' => 'Secret-shaped source value',
|
|
'summary' => 'SQLSTATE raw error with access_token should never appear.',
|
|
],
|
|
],
|
|
]);
|
|
Queue::fake();
|
|
|
|
$service = app(ManagementReportPdfService::class);
|
|
$result = $service->startGeneration($pack, $user);
|
|
|
|
expect($result['mode'])->toBe('queued')
|
|
->and($result['report'])->toBeInstanceOf(StoredReport::class)
|
|
->and($result['operation_run'])->toBeInstanceOf(OperationRun::class);
|
|
|
|
Queue::assertPushed(GenerateManagementReportPdfJob::class);
|
|
|
|
/** @var StoredReport $report */
|
|
$report = $result['report'];
|
|
/** @var OperationRun $run */
|
|
$run = $result['operation_run'];
|
|
|
|
expect($run->type)->toBe(OperationRunType::ManagementReportGenerate->value)
|
|
->and($report->status)->toBe(StoredReport::STATUS_QUEUED)
|
|
->and($report->source_review_pack_id)->toBe((int) $pack->getKey())
|
|
->and($report->source_environment_review_id)->toBe((int) $review->getKey());
|
|
|
|
Http::fake([
|
|
'gotenberg.test/forms/chromium/convert/html' => Http::response('%PDF-1.7 rendered spec379', 200, [
|
|
'Content-Type' => 'application/pdf',
|
|
'Gotenberg-Trace' => 'spec379-render',
|
|
]),
|
|
]);
|
|
|
|
app()->call([new GenerateManagementReportPdfJob(
|
|
storedReportId: (int) $report->getKey(),
|
|
operationRunId: (int) $run->getKey(),
|
|
), 'handle']);
|
|
|
|
$report->refresh();
|
|
$run->refresh();
|
|
|
|
expect($report->status)->toBe(StoredReport::STATUS_READY)
|
|
->and($report->file_disk)->toBe('exports')
|
|
->and(Storage::disk('exports')->exists((string) $report->file_path))->toBeTrue()
|
|
->and($report->sha256)->toBe(hash('sha256', '%PDF-1.7 rendered spec379'))
|
|
->and(json_encode($report->payload, JSON_THROW_ON_ERROR))->not->toContain('SQLSTATE', 'access_token')
|
|
->and($run->status)->toBe(OperationRunStatus::Completed->value)
|
|
->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value)
|
|
->and($run->summary_counts)->toMatchArray([
|
|
'total' => 1,
|
|
'processed' => 1,
|
|
'succeeded' => 1,
|
|
'report_created' => 1,
|
|
]);
|
|
|
|
Http::assertSent(fn (Request $request): bool => $request->url() === 'http://gotenberg.test/forms/chromium/convert/html'
|
|
&& $request->method() === 'POST');
|
|
|
|
$audit = AuditLog::query()
|
|
->where('action', AuditActionId::ManagementReportPdfGenerated->value)
|
|
->latest('id')
|
|
->first();
|
|
|
|
expect($audit)->not->toBeNull()
|
|
->and($audit?->workspace_id)->toBe((int) $tenant->workspace_id)
|
|
->and($audit?->resource_type)->toBe('stored_report')
|
|
->and(data_get($audit?->metadata, 'stored_report_id'))->toBe((int) $report->getKey());
|
|
});
|
|
|
|
it('Spec379 builds a customer-executive payload without profile fallback or sensitive source strings', function (): void {
|
|
[$user, , , $pack] = spec379CurrentReadyPack([
|
|
'decision_summary' => [
|
|
'entries' => [
|
|
[
|
|
'title' => 'Token shaped value',
|
|
'summary' => 'Bearer abc.def access_token=secret should be redacted.',
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$payload = app(ManagementReportPdfPayloadBuilder::class)->build($pack);
|
|
$encoded = json_encode($payload, JSON_THROW_ON_ERROR);
|
|
|
|
expect($payload['profile'])->toBe(ReportProfileRegistry::CUSTOMER_EXECUTIVE)
|
|
->and($encoded)->toContain('Executive summary')
|
|
->and($encoded)->toContain('Method summary')
|
|
->and($encoded)->not->toContain('Bearer abc.def', 'access_token=secret', 'Internal MSP review')
|
|
->and($user)->not->toBeNull();
|
|
});
|
|
|
|
it('Spec379 maps source and disclosure blockers before rendering', function (): void {
|
|
spec379ConfigurePdfRenderer();
|
|
[$user, , $review, $pack] = spec379CurrentReadyPack();
|
|
Queue::fake();
|
|
$service = app(ManagementReportPdfService::class);
|
|
|
|
Storage::disk('exports')->delete((string) $pack->file_path);
|
|
expect($service->generationDecision($pack)['reason_code'])->toBe('source_artifact_missing');
|
|
|
|
Storage::disk('exports')->put((string) $pack->file_path, spec379ZipContents());
|
|
$pack->forceFill(['expires_at' => now()->subMinute()])->save();
|
|
expect($service->generationDecision($pack->fresh())['reason_code'])->toBe('review_pack_expired');
|
|
|
|
$pack->forceFill(['expires_at' => now()->addDay()])->save();
|
|
$review->forceFill(['current_export_review_pack_id' => null])->save();
|
|
expect($service->generationDecision($pack->fresh(['environmentReview']))['reason_code'])->toBe('review_pack_not_current');
|
|
|
|
$review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save();
|
|
$pack->forceFill([
|
|
'options' => [
|
|
'include_pii' => true,
|
|
'include_operations' => true,
|
|
],
|
|
])->save();
|
|
|
|
$runCount = OperationRun::query()->count();
|
|
$reportCount = StoredReport::query()
|
|
->where('report_type', StoredReport::REPORT_TYPE_MANAGEMENT_REPORT_PDF)
|
|
->count();
|
|
$disclosureDecision = $service->generationDecision($pack->fresh(['environmentReview']));
|
|
$result = $service->startGeneration($pack->fresh(['tenant', 'environmentReview']), $user);
|
|
|
|
expect($disclosureDecision['is_blocked'])->toBeTrue()
|
|
->and($disclosureDecision['reason_code'])->toBe('customer_profile_internal_only')
|
|
->and($result['mode'])->toBe('blocked')
|
|
->and(OperationRun::query()->count())->toBe($runCount)
|
|
->and(StoredReport::query()->where('report_type', StoredReport::REPORT_TYPE_MANAGEMENT_REPORT_PDF)->count())->toBe($reportCount);
|
|
|
|
Queue::assertNotPushed(GenerateManagementReportPdfJob::class);
|
|
|
|
expect(fn () => app(ManagementReportPdfPayloadBuilder::class)->build($pack->fresh(['environmentReview'])))
|
|
->toThrow(InvalidArgumentException::class);
|
|
});
|
|
|
|
it('Spec379 marks queued generation as blocked when disclosure changes before the job runs', function (): void {
|
|
Queue::fake();
|
|
spec379ConfigurePdfRenderer();
|
|
[$user, , , $pack] = spec379CurrentReadyPack();
|
|
$result = app(ManagementReportPdfService::class)->startGeneration($pack, $user);
|
|
|
|
/** @var StoredReport $report */
|
|
$report = $result['report'];
|
|
/** @var OperationRun $run */
|
|
$run = $result['operation_run'];
|
|
|
|
$pack->forceFill([
|
|
'options' => [
|
|
'include_pii' => true,
|
|
'include_operations' => true,
|
|
],
|
|
])->save();
|
|
|
|
Http::fake();
|
|
|
|
app()->call([new GenerateManagementReportPdfJob(
|
|
storedReportId: (int) $report->getKey(),
|
|
operationRunId: (int) $run->getKey(),
|
|
), 'handle']);
|
|
|
|
$report->refresh();
|
|
$run->refresh();
|
|
|
|
expect($report->status)->toBe(StoredReport::STATUS_FAILED)
|
|
->and(data_get($report->payload, 'blocked'))->toBeTrue()
|
|
->and($report->file_path)->toBeNull()
|
|
->and($run->status)->toBe(OperationRunStatus::Completed->value)
|
|
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
|
->and($run->summary_counts)->toMatchArray([
|
|
'total' => 1,
|
|
'processed' => 1,
|
|
'succeeded' => 0,
|
|
'failed' => 0,
|
|
])
|
|
->and(data_get($run->failure_summary, '0.code'))->toBe('management_report_pdf.customer_profile_internal_only');
|
|
|
|
Http::assertNothingSent();
|
|
|
|
$audit = AuditLog::query()
|
|
->where('action', AuditActionId::ManagementReportPdfGenerationBlocked->value)
|
|
->latest('id')
|
|
->first();
|
|
|
|
expect($audit)->not->toBeNull()
|
|
->and(data_get($audit?->metadata, 'reason_code'))->toBe('customer_profile_internal_only');
|
|
});
|
|
|
|
it('Spec379 records renderer failure without exposing a ready artifact', function (): void {
|
|
Queue::fake();
|
|
spec379ConfigurePdfRenderer();
|
|
[$user, , , $pack] = spec379CurrentReadyPack();
|
|
$result = app(ManagementReportPdfService::class)->startGeneration($pack, $user);
|
|
|
|
/** @var StoredReport $report */
|
|
$report = $result['report'];
|
|
/** @var OperationRun $run */
|
|
$run = $result['operation_run'];
|
|
|
|
Http::fake([
|
|
'gotenberg.test/forms/chromium/convert/html' => Http::sequence()
|
|
->push('renderer failed', 500, [
|
|
'Content-Type' => 'text/plain',
|
|
])
|
|
->push('%PDF-1.7 rendered spec379 retry', 200, [
|
|
'Content-Type' => 'application/pdf',
|
|
'Gotenberg-Trace' => 'spec379-renderer-retry',
|
|
]),
|
|
]);
|
|
|
|
app()->call([new GenerateManagementReportPdfJob(
|
|
storedReportId: (int) $report->getKey(),
|
|
operationRunId: (int) $run->getKey(),
|
|
), 'handle']);
|
|
|
|
$report->refresh();
|
|
$run->refresh();
|
|
|
|
expect($report->status)->toBe(StoredReport::STATUS_FAILED)
|
|
->and($report->file_path)->toBeNull()
|
|
->and($run->status)->toBe(OperationRunStatus::Completed->value)
|
|
->and($run->outcome)->toBe(OperationRunOutcome::Failed->value)
|
|
->and(data_get($run->failure_summary, '0.code'))->toBe('management_report_pdf.renderer_failed');
|
|
|
|
Queue::fake();
|
|
$retry = app(ManagementReportPdfService::class)->startGeneration($pack->fresh(['tenant', 'environmentReview']), $user);
|
|
|
|
/** @var StoredReport $retryReport */
|
|
$retryReport = $retry['report'];
|
|
/** @var OperationRun $retryRun */
|
|
$retryRun = $retry['operation_run'];
|
|
|
|
expect($retry['mode'])->toBe('queued')
|
|
->and($retryReport->getKey())->toBe($report->getKey())
|
|
->and($retryReport->status)->toBe(StoredReport::STATUS_QUEUED)
|
|
->and($retryReport->operation_run_id)->toBe((int) $retryRun->getKey())
|
|
->and($retryRun->getKey())->not->toBe($run->getKey());
|
|
|
|
Queue::assertPushed(GenerateManagementReportPdfJob::class);
|
|
|
|
app()->call([new GenerateManagementReportPdfJob(
|
|
storedReportId: (int) $retryReport->getKey(),
|
|
operationRunId: (int) $retryRun->getKey(),
|
|
), 'handle']);
|
|
|
|
$retryReport->refresh();
|
|
$retryRun->refresh();
|
|
|
|
expect($retryReport->status)->toBe(StoredReport::STATUS_READY)
|
|
->and($retryReport->file_path)->not->toBeNull()
|
|
->and($retryRun->status)->toBe(OperationRunStatus::Completed->value)
|
|
->and($retryRun->outcome)->toBe(OperationRunOutcome::Succeeded->value);
|
|
|
|
Http::assertSentCount(2);
|
|
});
|
|
|
|
it('Spec379 records storage failure without exposing a ready artifact', function (): void {
|
|
Queue::fake();
|
|
spec379ConfigurePdfRenderer();
|
|
[$user, , , $pack] = spec379CurrentReadyPack();
|
|
$result = app(ManagementReportPdfService::class)->startGeneration($pack, $user);
|
|
|
|
/** @var StoredReport $report */
|
|
$report = $result['report'];
|
|
/** @var OperationRun $run */
|
|
$run = $result['operation_run'];
|
|
|
|
Http::fake([
|
|
'gotenberg.test/forms/chromium/convert/html' => Http::response('%PDF-1.7 rendered spec379', 200, [
|
|
'Content-Type' => 'application/pdf',
|
|
'Gotenberg-Trace' => 'spec379-storage-failure',
|
|
]),
|
|
]);
|
|
|
|
$fakeDisk = \Mockery::mock(\Illuminate\Contracts\Filesystem\Filesystem::class);
|
|
$fakeDisk->shouldReceive('put')
|
|
->once()
|
|
->andReturnFalse();
|
|
|
|
Storage::shouldReceive('disk')
|
|
->with('exports')
|
|
->andReturn($fakeDisk);
|
|
|
|
app()->call([new GenerateManagementReportPdfJob(
|
|
storedReportId: (int) $report->getKey(),
|
|
operationRunId: (int) $run->getKey(),
|
|
), 'handle']);
|
|
|
|
$report->refresh();
|
|
$run->refresh();
|
|
|
|
expect($report->status)->toBe(StoredReport::STATUS_FAILED)
|
|
->and($report->file_path)->toBeNull()
|
|
->and($report->sha256)->toBeNull()
|
|
->and($run->status)->toBe(OperationRunStatus::Completed->value)
|
|
->and($run->outcome)->toBe(OperationRunOutcome::Failed->value)
|
|
->and(data_get($run->failure_summary, '0.code'))->toBe('management_report_pdf.storage_failed');
|
|
});
|
|
|
|
it('Spec379 downloads a ready management PDF only through the signed tenant-authorized route', function (): void {
|
|
[$user, $tenant, , $pack] = spec379CurrentReadyPack();
|
|
$report = spec379ReadyManagementPdf($pack);
|
|
$url = app(ManagementReportPdfService::class)->generateDownloadUrl($report, [
|
|
'source_surface' => 'spec379',
|
|
]);
|
|
|
|
$this->actingAs($user)
|
|
->get($url)
|
|
->assertOk()
|
|
->assertHeader('X-Management-Report-PDF-SHA256', $report->sha256)
|
|
->assertDownload();
|
|
|
|
$audit = AuditLog::query()
|
|
->where('action', AuditActionId::ManagementReportPdfDownloaded->value)
|
|
->latest('id')
|
|
->first();
|
|
|
|
expect($audit)->not->toBeNull()
|
|
->and($audit?->resource_type)->toBe('stored_report')
|
|
->and(data_get($audit?->metadata, 'stored_report_id'))->toBe((int) $report->getKey())
|
|
->and(data_get($audit?->metadata, 'source_surface'))->toBe('spec379');
|
|
|
|
[$otherUser] = createUserWithTenant(role: 'owner');
|
|
|
|
$this->actingAs($otherUser)
|
|
->get($url)
|
|
->assertNotFound();
|
|
|
|
expect($tenant)->not->toBeNull();
|
|
});
|
|
|
|
it('Spec379 enforces scoped authorization for generation and download', function (): void {
|
|
spec379ConfigurePdfRenderer();
|
|
[$owner, , , $pack] = spec379CurrentReadyPack();
|
|
[$readonly] = createUserWithTenant(tenant: $pack->tenant, user: \App\Models\User::factory()->create(), role: 'readonly', clearCapabilityCaches: true);
|
|
$report = spec379ReadyManagementPdf($pack);
|
|
$url = app(ManagementReportPdfService::class)->generateDownloadUrl($report);
|
|
|
|
try {
|
|
app(ManagementReportPdfService::class)->startGeneration($pack, $readonly);
|
|
$this->fail('Scoped readonly actor should not start management PDF generation.');
|
|
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $exception) {
|
|
expect($exception->getStatusCode())->toBe(403);
|
|
}
|
|
|
|
Gate::define(
|
|
Capabilities::REVIEW_PACK_VIEW,
|
|
fn (User $actor, ManagedEnvironment $tenant): bool => (int) $actor->getKey() !== (int) $readonly->getKey()
|
|
|| (int) $tenant->getKey() !== (int) $pack->tenant->getKey(),
|
|
);
|
|
|
|
$this->actingAs($readonly)
|
|
->get($url)
|
|
->assertForbidden();
|
|
|
|
[$outsideUser] = createUserWithTenant(role: 'owner', clearCapabilityCaches: true);
|
|
|
|
$this->actingAs($outsideUser)
|
|
->get($url)
|
|
->assertNotFound();
|
|
|
|
expect($owner)->not->toBeNull();
|
|
});
|
|
|
|
it('Spec379 exposes the confirmed Filament action only when generation is available', function (): void {
|
|
spec379ConfigurePdfRenderer(runtimeValidated: false);
|
|
[$user, $tenant, , $pack] = spec379CurrentReadyPack();
|
|
|
|
setAdminEnvironmentContext($tenant);
|
|
|
|
Livewire::actingAs($user)
|
|
->test(ViewReviewPack::class, ['record' => $pack->getKey()])
|
|
->assertActionVisible('generate_management_report_pdf')
|
|
->assertActionDisabled('generate_management_report_pdf');
|
|
|
|
spec379ConfigurePdfRenderer(runtimeValidated: true);
|
|
|
|
Livewire::actingAs($user)
|
|
->test(ViewReviewPack::class, ['record' => $pack->getKey()])
|
|
->assertActionVisible('generate_management_report_pdf')
|
|
->assertActionEnabled('generate_management_report_pdf')
|
|
->assertActionExists('generate_management_report_pdf', fn (Action $action): bool => $action->isConfirmationRequired())
|
|
->mountAction('generate_management_report_pdf')
|
|
->assertActionMounted('generate_management_report_pdf');
|
|
});
|
|
|
|
it('Spec379 exposes an unavailable Filament action when disclosure blocks customer PDF generation', function (): void {
|
|
spec379ConfigurePdfRenderer();
|
|
[$user, $tenant, , $pack] = spec379CurrentReadyPack();
|
|
|
|
$pack->forceFill([
|
|
'options' => [
|
|
'include_pii' => true,
|
|
'include_operations' => true,
|
|
],
|
|
])->save();
|
|
|
|
setAdminEnvironmentContext($tenant);
|
|
|
|
Livewire::actingAs($user)
|
|
->test(ViewReviewPack::class, ['record' => $pack->getKey()])
|
|
->assertActionVisible('generate_management_report_pdf')
|
|
->assertActionDisabled('generate_management_report_pdf')
|
|
->assertActionExists('generate_management_report_pdf', fn (Action $action): bool => $action->getLabel() === __('localization.review.management_report_pdf_blocked'));
|
|
});
|
|
|
|
it('Spec379 regenerates a review-bound pack from the Review Pack detail action', function (): void {
|
|
Queue::fake();
|
|
[$user, $tenant, $review, $pack] = spec379CurrentReadyPack();
|
|
$tenantWidePackCount = ReviewPack::query()
|
|
->where('managed_environment_id', (int) $tenant->getKey())
|
|
->whereNull('environment_review_id')
|
|
->count();
|
|
|
|
setAdminEnvironmentContext($tenant);
|
|
|
|
Livewire::actingAs($user)
|
|
->test(ViewReviewPack::class, ['record' => $pack->getKey()])
|
|
->callAction('regenerate', data: [
|
|
'include_pii' => true,
|
|
'include_operations' => true,
|
|
])
|
|
->assertNotified();
|
|
|
|
$regeneratedPack = ReviewPack::query()
|
|
->where('environment_review_id', (int) $review->getKey())
|
|
->whereKeyNot((int) $pack->getKey())
|
|
->latest('id')
|
|
->firstOrFail();
|
|
|
|
expect($regeneratedPack->options)->toMatchArray([
|
|
'include_pii' => true,
|
|
'include_operations' => true,
|
|
])
|
|
->and($regeneratedPack->managed_environment_id)->toBe((int) $tenant->getKey())
|
|
->and($regeneratedPack->evidence_snapshot_id)->toBe((int) $review->evidence_snapshot_id)
|
|
->and(ReviewPack::query()
|
|
->where('managed_environment_id', (int) $tenant->getKey())
|
|
->whereNull('environment_review_id')
|
|
->count())->toBe($tenantWidePackCount);
|
|
|
|
Queue::assertPushed(GenerateReviewPackJob::class);
|
|
});
|
|
|
|
it('Spec379 swaps the generate action for the download action once a PDF exists', function (): void {
|
|
[$user, $tenant, , $pack] = spec379CurrentReadyPack();
|
|
spec379ReadyManagementPdf($pack);
|
|
|
|
setAdminEnvironmentContext($tenant);
|
|
|
|
Livewire::actingAs($user)
|
|
->test(ViewReviewPack::class, ['record' => $pack->getKey()])
|
|
->assertActionVisible('download_management_report_pdf')
|
|
->assertActionDoesNotExist('generate_management_report_pdf');
|
|
});
|