Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 5m7s
Added jobs, controllers, and PDF generation logic for management report runtime as defined in Spec 379. Includes artifact migrations, payload builders, and testing coverage.
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');
|
|
});
|