feat: implement decision register summary in environment review packs

This commit is contained in:
Ahmed Darrazi 2026-05-15 14:52:06 +02:00
parent ca30ca95bf
commit 4fc8308540
8 changed files with 451 additions and 22 deletions

View File

@ -279,6 +279,9 @@ private function executeReviewDerivedGeneration(
$fingerprint = app(ReviewPackService::class)->computeFingerprintForReview($review, $options);
$reviewSummary = is_array($review->summary) ? $review->summary : [];
$governancePackage = is_array($reviewSummary['governance_package'] ?? null)
? $this->redactReportPayload($reviewSummary['governance_package'], $includePii)
: [];
$summary = [
'environment_review_id' => (int) $review->getKey(),
'review_status' => (string) $review->status,
@ -289,6 +292,7 @@ private function executeReviewDerivedGeneration(
'operation_count' => $includeOperations ? (int) ($reviewSummary['operation_count'] ?? 0) : 0,
'highlights' => is_array($reviewSummary['highlights'] ?? null) ? $reviewSummary['highlights'] : [],
'publish_blockers' => is_array($reviewSummary['publish_blockers'] ?? null) ? $reviewSummary['publish_blockers'] : [],
'governance_package' => $governancePackage,
'delivery_bundle' => $this->deliveryBundleSummary($review),
'evidence_resolution' => [
'outcome' => 'resolved',
@ -541,7 +545,24 @@ private function redactReportPayload(array $payload, bool $includePii): array
*/
private function redactArrayPii(array $data): array
{
$piiKeys = ['displayName', 'display_name', 'userPrincipalName', 'user_principal_name', 'email', 'mail'];
$piiKeys = [
'displayName',
'display_name',
'userPrincipalName',
'user_principal_name',
'email',
'mail',
'tenant_name',
'tenant_label',
'customer_name',
'owner_label',
'owner_name',
'actor_label',
'actor_name',
'initiator_name',
'requested_by',
'approved_by',
];
foreach ($data as $key => $value) {
if (is_string($key) && in_array($key, $piiKeys, true)) {
@ -825,7 +846,10 @@ private function buildExecutiveEntrypoint(
$tenantName = $includePii ? $tenant->name : '[REDACTED]';
$topFindings = is_array($package['top_findings'] ?? null) ? $package['top_findings'] : [];
$acceptedRisks = is_array($package['accepted_risks'] ?? null) ? $package['accepted_risks'] : [];
$governanceDecisions = is_array($package['governance_decisions'] ?? null) ? $package['governance_decisions'] : [];
$decisionSummary = is_array($package['decision_summary'] ?? null) ? $package['decision_summary'] : [];
$governanceDecisions = is_array($decisionSummary['entries'] ?? null)
? $decisionSummary['entries']
: (is_array($package['governance_decisions'] ?? null) ? $package['governance_decisions'] : []);
$nextActions = is_array($reviewSummary['recommended_next_actions'] ?? null) ? $reviewSummary['recommended_next_actions'] : [];
$lines = [
@ -857,7 +881,7 @@ private function buildExecutiveEntrypoint(
'',
'## Governance decisions requiring awareness',
'',
...$this->entryBullets($governanceDecisions, 'No governance decisions require awareness in this released review.'),
...$this->decisionSummaryLines($decisionSummary, $governanceDecisions),
'',
'## Next actions',
'',
@ -876,6 +900,35 @@ private function buildExecutiveEntrypoint(
return implode("\n", $lines);
}
/**
* @param array<string, mixed> $decisionSummary
* @param array<int, mixed> $entries
* @return list<string>
*/
private function decisionSummaryLines(array $decisionSummary, array $entries): array
{
$lines = [];
$summary = $this->plainText($decisionSummary['summary'] ?? null, '');
$nextAction = $this->plainText($decisionSummary['next_action'] ?? null, '');
if ($summary !== '') {
$lines[] = $summary;
}
if ($nextAction !== '') {
$lines[] = 'Next action: '.$nextAction;
}
if ($lines !== []) {
$lines[] = '';
}
return [
...$lines,
...$this->entryBullets($entries, 'No governance decisions require awareness in this released review.'),
];
}
/**
* @param array<int, mixed> $entries
* @return list<string>

View File

@ -148,6 +148,11 @@ private function governancePackageSummary(
->map(fn (array $entry): array => $this->packageGovernanceDecisionEntry($entry))
->values()
->all();
$decisionSummary = $this->packageDecisionSummary(
snapshot: $snapshot,
acceptedRisksSection: $acceptedRisksSection,
governanceDecisions: $governanceDecisions,
);
return [
'delivery_artifact_family' => 'review_pack',
@ -163,6 +168,7 @@ private function governancePackageSummary(
'top_findings' => $openRiskEntries,
'accepted_risks' => $stableAcceptedRiskEntries->all(),
'governance_decisions' => $governanceDecisions,
'decision_summary' => $decisionSummary,
'evidence_basis_summary' => $this->governancePackageEvidenceBasisSummary(
snapshot: $snapshot,
controlInterpretationSummary: $controlInterpretationSummary,
@ -236,15 +242,118 @@ private function packageGovernanceDecisionEntry(array $entry): array
'finding_id' => $entry['finding_id'] ?? null,
'title' => $entry['title'] ?? 'Governance decision',
'governance_state' => $governanceState,
'awareness_reason' => $this->governanceDecisionAwarenessReason($governanceState),
'summary' => match ($governanceState) {
'expired_exception' => 'The accepted-risk exception has expired and needs follow-up before stakeholder delivery.',
'revoked_exception' => 'The accepted-risk exception was revoked and needs follow-up before stakeholder delivery.',
'risk_accepted_without_valid_exception' => 'The accepted-risk entry has no currently valid exception basis and needs follow-up before stakeholder delivery.',
default => 'This governance decision needs follow-up before stakeholder delivery.',
},
'next_action' => $this->governanceDecisionNextAction($governanceState),
'evidence_basis' => 'Included in the anchored released-review evidence basis.',
];
}
/**
* @param array<string, mixed> $acceptedRisksSection
* @param list<array<string, mixed>> $governanceDecisions
* @return array<string, mixed>
*/
private function packageDecisionSummary(
EvidenceSnapshot $snapshot,
array $acceptedRisksSection,
array $governanceDecisions,
): array {
$totalCount = count($governanceDecisions);
$evidenceState = is_string($acceptedRisksSection['completeness_state'] ?? null)
? (string) $acceptedRisksSection['completeness_state']
: (string) $snapshot->completeness_state;
$decisionDataState = $totalCount > 0 || $this->decisionEvidenceIsAvailable($evidenceState)
? 'available'
: 'incomplete';
$status = match (true) {
$totalCount > 0 => 'requires_awareness',
$decisionDataState === 'incomplete' => 'unavailable',
default => 'none',
};
return [
'customer_safe' => true,
'status' => $status,
'decision_data_state' => $decisionDataState,
'evidence_state' => $evidenceState,
'total_count' => $totalCount,
'requires_awareness' => $totalCount > 0,
'summary' => $this->decisionSummaryText($status, $totalCount),
'empty_state' => $this->decisionSummaryEmptyState($status),
'next_action' => $this->decisionSummaryNextAction($status),
'evidence_basis' => sprintf(
'Anchored to evidence snapshot #%d with %s decision-evidence completeness.',
(int) $snapshot->getKey(),
$evidenceState,
),
'source_section_state' => is_string($acceptedRisksSection['completeness_state'] ?? null)
? $acceptedRisksSection['completeness_state']
: null,
'entries' => $governanceDecisions,
];
}
private function decisionEvidenceIsAvailable(string $evidenceState): bool
{
return in_array($evidenceState, ['complete'], true);
}
private function decisionSummaryText(string $status, int $totalCount): string
{
return match ($status) {
'requires_awareness' => sprintf(
'%d governance decision%s require%s customer awareness before relying on this released review.',
$totalCount,
$totalCount === 1 ? '' : 's',
$totalCount === 1 ? 's' : '',
),
'unavailable' => 'Decision evidence is incomplete for this released review; no customer-aware decisions can be confirmed from the current evidence basis.',
default => 'No governance decisions require customer awareness in this released review.',
};
}
private function decisionSummaryEmptyState(string $status): string
{
return match ($status) {
'unavailable' => 'Decision evidence is incomplete in the current released-review basis.',
'requires_awareness' => '',
default => 'No governance decisions require awareness in this released review.',
};
}
private function decisionSummaryNextAction(string $status): string
{
return match ($status) {
'requires_awareness' => 'Review the accepted-risk decision basis before customer delivery.',
'unavailable' => 'Open the review evidence before treating the decision register summary as complete.',
default => 'No customer action is needed for Decision Register follow-up from this review.',
};
}
private function governanceDecisionAwarenessReason(string $governanceState): string
{
return match ($governanceState) {
'expired_exception' => 'The accepted-risk approval has expired and needs customer awareness before the review is relied on.',
'revoked_exception' => 'The accepted-risk approval was revoked and needs customer awareness before the review is relied on.',
'risk_accepted_without_valid_exception' => 'The accepted risk has no valid governance backing in the released review evidence.',
default => 'This accepted-risk governance decision needs customer awareness before the review is relied on.',
};
}
private function governanceDecisionNextAction(string $governanceState): string
{
return match ($governanceState) {
'expired_exception', 'revoked_exception', 'risk_accepted_without_valid_exception' => 'Confirm whether this accepted risk should be renewed, remediated, or removed before relying on the review.',
default => 'Confirm the accepted-risk decision before relying on the review.',
};
}
/**
* @param array<string, mixed> $executiveSummaryPayload
* @param array<string, mixed> $executiveRenderPayload

View File

@ -50,8 +50,8 @@ public function collect(ManagedEnvironment $tenant): array
'finding_type' => (string) $finding->finding_type,
'severity' => (string) $finding->severity,
'status' => (string) $finding->status,
'title' => $finding->title,
'description' => $finding->description,
'title' => $finding->resolvedSubjectDisplayName(),
'description' => null,
'created_at' => $finding->created_at?->toIso8601String(),
'updated_at' => $finding->updated_at?->toIso8601String(),
'verification_state' => $outcome['verification_state'],

View File

@ -371,6 +371,7 @@
'download_governance_package' => 'Governance-Paket herunterladen',
'governance_package' => 'Governance-Paket',
'governance_decisions' => 'Governance-Entscheidungen',
'governance_decisions_requiring_awareness' => 'Governance-Entscheidungen mit Aufmerksamkeitsbedarf',
'governance_package_delivery_note' => 'Dieses Governance-Paket wird über das aktuelle Export-Review-Pack des veröffentlichten Reviews ausgeliefert.',
'executive_entrypoint' => 'Executive-Einstieg',
'executive_entrypoint_description' => 'Beginnen Sie im heruntergeladenen Paket mit executive-summary.md.',

View File

@ -371,6 +371,7 @@
'download_governance_package' => 'Download governance package',
'governance_package' => 'Governance package',
'governance_decisions' => 'Governance decisions',
'governance_decisions_requiring_awareness' => 'Governance decisions requiring awareness',
'governance_package_delivery_note' => 'This governance package is delivered through the current export review pack for the released review.',
'executive_entrypoint' => 'Executive entrypoint',
'executive_entrypoint_description' => 'Start with executive-summary.md in the downloaded package.',

View File

@ -16,7 +16,13 @@
$packageAvailability = is_array($governancePackage['availability'] ?? null) ? $governancePackage['availability'] : [];
$packageTopFindings = is_array($governancePackage['top_findings'] ?? null) ? $governancePackage['top_findings'] : [];
$packageAcceptedRisks = is_array($governancePackage['accepted_risks'] ?? null) ? $governancePackage['accepted_risks'] : [];
$packageGovernanceDecisions = is_array($governancePackage['governance_decisions'] ?? null) ? $governancePackage['governance_decisions'] : [];
$packageDecisionSummary = is_array($governancePackage['decision_summary'] ?? null) ? $governancePackage['decision_summary'] : [];
$packageGovernanceDecisions = is_array($packageDecisionSummary['entries'] ?? null)
? $packageDecisionSummary['entries']
: (is_array($governancePackage['governance_decisions'] ?? null) ? $governancePackage['governance_decisions'] : []);
$packageDecisionSummaryText = is_string($packageDecisionSummary['summary'] ?? null) ? trim((string) $packageDecisionSummary['summary']) : null;
$packageDecisionNextAction = is_string($packageDecisionSummary['next_action'] ?? null) ? trim((string) $packageDecisionSummary['next_action']) : null;
$packageDecisionEmptyState = is_string($packageDecisionSummary['empty_state'] ?? null) ? trim((string) $packageDecisionSummary['empty_state']) : null;
$controlControls = is_array($controlInterpretation['controls'] ?? null) ? $controlInterpretation['controls'] : [];
$controlVersion = is_string($controlInterpretation['version_key'] ?? null) ? $controlInterpretation['version_key'] : null;
$controlDisclosure = is_string($controlInterpretation['non_certification_disclosure'] ?? null)
@ -205,24 +211,47 @@
</div>
@endif
@if ($packageGovernanceDecisions !== [])
@if ($packageDecisionSummary !== [] || $packageGovernanceDecisions !== [])
<div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.governance_decisions') }}</div>
<ul class="space-y-1 text-sm text-amber-800 dark:text-amber-200">
@foreach ($packageGovernanceDecisions as $decision)
@php
$decisionTitle = is_string($decision['title'] ?? null) ? $decision['title'] : __('localization.review.governance_decisions');
$decisionSummary = is_string($decision['summary'] ?? null) ? $decision['summary'] : null;
@endphp
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.governance_decisions_requiring_awareness') }}</div>
<li class="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 dark:border-amber-900/40 dark:bg-amber-950/30">
<div class="font-medium">{{ $decisionTitle }}</div>
@if ($decisionSummary !== null && trim($decisionSummary) !== '')
<div class="mt-1 text-xs">{{ $decisionSummary }}</div>
@endif
</li>
@endforeach
</ul>
@if ($packageDecisionSummaryText !== null && $packageDecisionSummaryText !== '')
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950/60 dark:text-gray-300">
{{ $packageDecisionSummaryText }}
</div>
@endif
@if ($packageDecisionNextAction !== null && $packageDecisionNextAction !== '')
<div class="rounded-md border border-primary-100 bg-primary-50 px-3 py-2 text-sm text-primary-900 dark:border-primary-900/40 dark:bg-primary-950/30 dark:text-primary-100">
{{ $packageDecisionNextAction }}
</div>
@endif
@if ($packageGovernanceDecisions !== [])
<ul class="space-y-1 text-sm text-amber-800 dark:text-amber-200">
@foreach ($packageGovernanceDecisions as $decision)
@php
$decisionTitle = is_string($decision['title'] ?? null) ? $decision['title'] : __('localization.review.governance_decisions');
$decisionSummary = is_string($decision['summary'] ?? null) ? $decision['summary'] : null;
$decisionNextAction = is_string($decision['next_action'] ?? null) ? $decision['next_action'] : null;
@endphp
<li class="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 dark:border-amber-900/40 dark:bg-amber-950/30">
<div class="font-medium">{{ $decisionTitle }}</div>
@if ($decisionSummary !== null && trim($decisionSummary) !== '')
<div class="mt-1 text-xs">{{ $decisionSummary }}</div>
@endif
@if ($decisionNextAction !== null && trim($decisionNextAction) !== '')
<div class="mt-2 text-xs">{{ $decisionNextAction }}</div>
@endif
</li>
@endforeach
</ul>
@elseif ($packageDecisionEmptyState !== null && $packageDecisionEmptyState !== '')
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950/60 dark:text-gray-300">
{{ $packageDecisionEmptyState }}
</div>
@endif
</div>
@endif
</div>

View File

@ -5,7 +5,13 @@
use App\Filament\Resources\EnvironmentReviewResource;
use App\Filament\Widgets\ManagedEnvironment\ManagedEnvironmentReviewPackCard;
use App\Jobs\GenerateReviewPackJob;
use App\Models\Finding;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Services\Findings\FindingExceptionService;
use App\Services\Findings\FindingRiskGovernanceResolver;
use App\Services\ReviewPackService;
use App\Support\EnvironmentReviewStatus;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
@ -13,6 +19,40 @@
Storage::fake('exports');
});
function spec308SeedExpiredDecisionFinding(ManagedEnvironment $tenant, User $requester, string $title): Finding
{
$approver = User::factory()->create(['name' => 'Spec 308 Approver']);
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
/** @var FindingExceptionService $exceptionService */
$exceptionService = app(FindingExceptionService::class);
$finding = Finding::factory()->for($tenant)->riskAccepted()->create([
'fingerprint' => 'spec-308-raw-fingerprint-'.$title,
'evidence_jsonb' => [
'display_name' => $title,
'internal_url' => 'https://tenantpilot.test/admin/operations/raw-run',
],
]);
$requested = $exceptionService->request($finding, $tenant, $requester, [
'owner_user_id' => (int) $requester->getKey(),
'request_reason' => 'Temporary exception for staged remediation.',
'review_due_at' => now()->addDays(5)->toDateTimeString(),
'expires_at' => now()->addDays(14)->toDateTimeString(),
]);
$exceptionService->approve($requested, $approver, [
'effective_from' => now()->subDays(10)->toDateTimeString(),
'expires_at' => now()->subDay()->toDateTimeString(),
'approval_reason' => 'Approved with customer controls.',
]);
app(FindingRiskGovernanceResolver::class)->syncExceptionState($finding->findingException()->firstOrFail());
return $finding->refresh();
}
it('renders an executive-ready environment review and exports a pack with matching section order and summary truth', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$review = composeEnvironmentReviewForTest($tenant, $user);
@ -88,3 +128,93 @@
->test(ManagedEnvironmentReviewPackCard::class, ['record' => $tenant])
->assertSee('View review');
});
it('builds an explicit customer-safe decision summary for released review consumption', function (): void {
[$user, $tenant] = createUserWithTenant(
tenant: ManagedEnvironment::factory()->create(['name' => 'Visible Customer Environment']),
role: 'owner',
);
spec308SeedExpiredDecisionFinding($tenant, $user, 'Privileged role exception');
$hiddenTenant = ManagedEnvironment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'name' => 'Hidden Customer Environment',
]);
[$hiddenUser, $hiddenTenant] = createUserWithTenant(tenant: $hiddenTenant, role: 'owner');
spec308SeedExpiredDecisionFinding($hiddenTenant, $hiddenUser, 'Hidden tenant exception');
$snapshot = seedEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
$review = composeEnvironmentReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => EnvironmentReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$decisionSummary = data_get($review->fresh(), 'summary.governance_package.decision_summary');
expect($decisionSummary)->toBeArray()
->and($decisionSummary['status'] ?? null)->toBe('requires_awareness')
->and($decisionSummary['total_count'] ?? null)->toBe(1)
->and($decisionSummary['summary'] ?? null)->toContain('1 governance decision requires customer awareness')
->and($decisionSummary['next_action'] ?? null)->toBe('Review the accepted-risk decision basis before customer delivery.')
->and(data_get($decisionSummary, 'entries.0.title'))->toBe('Privileged role exception')
->and(data_get($decisionSummary, 'entries.0.awareness_reason'))->toContain('expired')
->and(data_get($decisionSummary, 'entries.0.next_action'))->toBe('Confirm whether this accepted risk should be renewed, remediated, or removed before relying on the review.')
->and(json_encode($decisionSummary, JSON_THROW_ON_ERROR))->not->toContain(
'Hidden tenant exception',
'spec-308-raw-fingerprint',
'raw-run',
'OperationRun',
'Reason owner',
'Platform reason family',
);
$this->actingAs($user)
->get(EnvironmentReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant).'?customer_workspace=1&source_surface=customer_review_workspace')
->assertOk()
->assertSee('Governance decisions requiring awareness')
->assertSee('Privileged role exception')
->assertSee('Review the accepted-risk decision basis before customer delivery.')
->assertDontSee('Hidden tenant exception')
->assertDontSee('raw-run')
->assertDontSeeText('Approve exception')
->assertDontSeeText('Reject exception')
->assertDontSeeText('Renew exception')
->assertDontSeeText('Revoke exception');
});
it('distinguishes no decision awareness from incomplete decision evidence', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$reviewWithNoDecisions = composeEnvironmentReviewForTest(
tenant: $tenant,
user: $user,
snapshot: seedEnvironmentReviewEvidence($tenant, findingCount: 2, driftCount: 0),
);
$noDecisionSummary = data_get($reviewWithNoDecisions, 'summary.governance_package.decision_summary');
expect($noDecisionSummary)->toBeArray()
->and($noDecisionSummary['status'] ?? null)->toBe('none')
->and($noDecisionSummary['total_count'] ?? null)->toBe(0)
->and($noDecisionSummary['summary'] ?? null)->toBe('No governance decisions require customer awareness in this released review.');
$partialTenant = ManagedEnvironment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'name' => 'Partial Evidence Environment',
]);
createUserWithTenant(tenant: $partialTenant, user: $user, role: 'owner');
$partialReview = composeEnvironmentReviewForTest(
tenant: $partialTenant,
user: $user,
snapshot: seedPartialEnvironmentReviewEvidence($partialTenant, findingCount: 0, driftCount: 0),
);
$unavailableSummary = data_get($partialReview, 'summary.governance_package.decision_summary');
expect($unavailableSummary)->toBeArray()
->and($unavailableSummary['status'] ?? null)->toBe('unavailable')
->and($unavailableSummary['total_count'] ?? null)->toBe(0)
->and($unavailableSummary['summary'] ?? null)->toContain('Decision evidence is incomplete');
});

View File

@ -4,7 +4,13 @@
use App\Filament\Resources\ReviewPackResource;
use App\Jobs\GenerateReviewPackJob;
use App\Models\Finding;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Services\Findings\FindingExceptionService;
use App\Services\Findings\FindingRiskGovernanceResolver;
use App\Services\ReviewPackService;
use App\Support\EnvironmentReviewStatus;
use App\Support\ReviewPackStatus;
use Illuminate\Support\Facades\Storage;
@ -12,6 +18,40 @@
Storage::fake('exports');
});
function spec308SeedPackDecisionFinding(ManagedEnvironment $tenant, User $requester, string $title): Finding
{
$approver = User::factory()->create(['name' => 'Risk Owner']);
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
/** @var FindingExceptionService $exceptionService */
$exceptionService = app(FindingExceptionService::class);
$finding = Finding::factory()->for($tenant)->riskAccepted()->create([
'fingerprint' => 'spec-308-pack-fingerprint',
'evidence_jsonb' => [
'display_name' => $title,
'internal_url' => 'https://tenantpilot.test/admin/operations/raw-review-pack-run',
],
]);
$requested = $exceptionService->request($finding, $tenant, $requester, [
'owner_user_id' => (int) $approver->getKey(),
'request_reason' => 'Customer owner approved temporary exception.',
'review_due_at' => now()->addDays(5)->toDateTimeString(),
'expires_at' => now()->addDays(14)->toDateTimeString(),
]);
$exceptionService->approve($requested, $approver, [
'effective_from' => now()->subDays(10)->toDateTimeString(),
'expires_at' => now()->subDay()->toDateTimeString(),
'approval_reason' => 'Approved with customer controls.',
]);
app(FindingRiskGovernanceResolver::class)->syncExceptionState($finding->findingException()->firstOrFail());
return $finding->refresh();
}
it('generates a review-derived executive pack with environment-review metadata and filtered sections', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$review = composeEnvironmentReviewForTest($tenant, $user);
@ -75,3 +115,69 @@
->assertSee('#'.$review->getKey())
->assertSee('Review status');
});
it('includes the customer-safe decision summary in review-derived pack JSON and markdown', function (): void {
[$user, $tenant] = createUserWithTenant(
tenant: ManagedEnvironment::factory()->create(['name' => 'Contoso Decision Tenant']),
role: 'owner',
);
spec308SeedPackDecisionFinding($tenant, $user, 'Privileged access accepted risk');
$snapshot = seedEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
$review = composeEnvironmentReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => EnvironmentReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$pack = app(ReviewPackService::class)->generateFromReview($review, $user, [
'include_pii' => false,
'include_operations' => false,
]);
$job = new GenerateReviewPackJob(
reviewPackId: (int) $pack->getKey(),
operationRunId: (int) $pack->operation_run_id,
);
app()->call([$job, 'handle']);
$pack->refresh();
expect(data_get($pack->summary, 'governance_package.decision_summary.status'))->toBe('requires_awareness')
->and(data_get($pack->summary, 'governance_package.decision_summary.total_count'))->toBe(1)
->and(data_get($pack->summary, 'delivery_bundle.contract'))->toBe(ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT);
$zipContent = Storage::disk('exports')->get((string) $pack->file_path);
$tempFile = tempnam(sys_get_temp_dir(), 'review-derived-pack-decisions-');
file_put_contents($tempFile, $zipContent);
$zip = new ZipArchive;
$zip->open($tempFile);
$filenames = collect(range(0, $zip->numFiles - 1))
->map(fn (int $index): string => (string) $zip->getNameIndex($index))
->values()
->all();
$metadata = json_decode((string) $zip->getFromName('metadata.json'), true, 512, JSON_THROW_ON_ERROR);
$summary = json_decode((string) $zip->getFromName('summary.json'), true, 512, JSON_THROW_ON_ERROR);
$executiveEntrypoint = (string) $zip->getFromName(ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME);
$summaryJson = json_encode($summary, JSON_THROW_ON_ERROR);
expect($filenames)->toContain('metadata.json', 'summary.json', 'sections.json', ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME)
->and(collect($filenames)->filter(fn (string $filename): bool => str_starts_with($filename, 'executive-'))->values()->all())->toBe([ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME])
->and(data_get($metadata, 'delivery_bundle.contract'))->toBe(ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT)
->and(data_get($summary, 'delivery_bundle.contract'))->toBe(ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT)
->and(data_get($summary, 'governance_package.decision_summary.status'))->toBe('requires_awareness')
->and(data_get($summary, 'governance_package.decision_summary.total_count'))->toBe(1)
->and(data_get($summary, 'governance_package.decision_summary.entries.0.title'))->toBe('Privileged access accepted risk')
->and($executiveEntrypoint)->toContain('## Governance decisions requiring awareness')
->and($executiveEntrypoint)->toContain('1 governance decision requires customer awareness')
->and($executiveEntrypoint)->toContain('Privileged access accepted risk')
->and($executiveEntrypoint)->toContain('Review the accepted-risk decision basis before customer delivery.')
->and($summaryJson)->not->toContain('Contoso Decision Tenant', 'Risk Owner', 'spec-308-pack-fingerprint', 'raw-review-pack-run')
->and($executiveEntrypoint)->not->toContain('Contoso Decision Tenant', 'Risk Owner', 'spec-308-pack-fingerprint', 'raw-review-pack-run', 'OperationRun URL');
$zip->close();
unlink($tempFile);
});