feat: implement decision register summary in environment review packs
This commit is contained in:
parent
ca30ca95bf
commit
4fc8308540
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user