From 4fc830854083d694f6885290cd8e8dc8b9058eae Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Fri, 15 May 2026 14:52:06 +0200 Subject: [PATCH] feat: implement decision register summary in environment review packs --- .../app/Jobs/GenerateReviewPackJob.php | 59 +++++++- .../EnvironmentReviewComposer.php | 109 +++++++++++++++ .../Sources/FindingsSummarySource.php | 4 +- apps/platform/lang/de/localization.php | 1 + apps/platform/lang/en/localization.php | 1 + .../environment-review-summary.blade.php | 63 ++++++--- .../EnvironmentReviewExecutivePackTest.php | 130 ++++++++++++++++++ ...EnvironmentReviewDerivedReviewPackTest.php | 106 ++++++++++++++ 8 files changed, 451 insertions(+), 22 deletions(-) diff --git a/apps/platform/app/Jobs/GenerateReviewPackJob.php b/apps/platform/app/Jobs/GenerateReviewPackJob.php index fe920fb4..32560521 100644 --- a/apps/platform/app/Jobs/GenerateReviewPackJob.php +++ b/apps/platform/app/Jobs/GenerateReviewPackJob.php @@ -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 $decisionSummary + * @param array $entries + * @return list + */ + 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 $entries * @return list diff --git a/apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewComposer.php b/apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewComposer.php index 79d680f9..65e71dfc 100644 --- a/apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewComposer.php +++ b/apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewComposer.php @@ -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 $acceptedRisksSection + * @param list> $governanceDecisions + * @return array + */ + 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 $executiveSummaryPayload * @param array $executiveRenderPayload diff --git a/apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php b/apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php index be00d405..124fa48e 100644 --- a/apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php +++ b/apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php @@ -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'], diff --git a/apps/platform/lang/de/localization.php b/apps/platform/lang/de/localization.php index 3d2b5d8b..2abab115 100644 --- a/apps/platform/lang/de/localization.php +++ b/apps/platform/lang/de/localization.php @@ -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.', diff --git a/apps/platform/lang/en/localization.php b/apps/platform/lang/en/localization.php index 147afaf8..fb674060 100644 --- a/apps/platform/lang/en/localization.php +++ b/apps/platform/lang/en/localization.php @@ -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.', diff --git a/apps/platform/resources/views/filament/infolists/entries/environment-review-summary.blade.php b/apps/platform/resources/views/filament/infolists/entries/environment-review-summary.blade.php index c0812aee..98f299c8 100644 --- a/apps/platform/resources/views/filament/infolists/entries/environment-review-summary.blade.php +++ b/apps/platform/resources/views/filament/infolists/entries/environment-review-summary.blade.php @@ -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 @@ @endif - @if ($packageGovernanceDecisions !== []) + @if ($packageDecisionSummary !== [] || $packageGovernanceDecisions !== [])
-
{{ __('localization.review.governance_decisions') }}
-
    - @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 +
    {{ __('localization.review.governance_decisions_requiring_awareness') }}
    -
  • -
    {{ $decisionTitle }}
    - @if ($decisionSummary !== null && trim($decisionSummary) !== '') -
    {{ $decisionSummary }}
    - @endif -
  • - @endforeach -
+ @if ($packageDecisionSummaryText !== null && $packageDecisionSummaryText !== '') +
+ {{ $packageDecisionSummaryText }} +
+ @endif + + @if ($packageDecisionNextAction !== null && $packageDecisionNextAction !== '') +
+ {{ $packageDecisionNextAction }} +
+ @endif + + @if ($packageGovernanceDecisions !== []) +
    + @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 + +
  • +
    {{ $decisionTitle }}
    + @if ($decisionSummary !== null && trim($decisionSummary) !== '') +
    {{ $decisionSummary }}
    + @endif + @if ($decisionNextAction !== null && trim($decisionNextAction) !== '') +
    {{ $decisionNextAction }}
    + @endif +
  • + @endforeach +
+ @elseif ($packageDecisionEmptyState !== null && $packageDecisionEmptyState !== '') +
+ {{ $packageDecisionEmptyState }} +
+ @endif
@endif diff --git a/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewExecutivePackTest.php b/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewExecutivePackTest.php index c51dff45..fc10eee1 100644 --- a/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewExecutivePackTest.php +++ b/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewExecutivePackTest.php @@ -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'); +}); diff --git a/apps/platform/tests/Feature/ReviewPack/EnvironmentReviewDerivedReviewPackTest.php b/apps/platform/tests/Feature/ReviewPack/EnvironmentReviewDerivedReviewPackTest.php index 7babcab5..4cb99ffd 100644 --- a/apps/platform/tests/Feature/ReviewPack/EnvironmentReviewDerivedReviewPackTest.php +++ b/apps/platform/tests/Feature/ReviewPack/EnvironmentReviewDerivedReviewPackTest.php @@ -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); +});