feat: enforce Spec392 customer output gating
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m35s
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m35s
This commit is contained in:
parent
40b866604a
commit
59b45becc1
@ -37,6 +37,7 @@
|
||||
use App\Support\ResolutionGuidance\Adapters\ReviewPackOutputResolutionAdapter;
|
||||
use App\Support\ResolutionGuidance\ResolutionAction;
|
||||
use App\Support\ResolutionGuidance\ResolutionCase;
|
||||
use App\Support\ReviewPacks\CustomerOutputGate;
|
||||
use App\Support\ReviewPacks\ReviewPackOutputReadiness;
|
||||
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
|
||||
use App\Support\ReviewPackStatus;
|
||||
@ -1950,6 +1951,12 @@ private function reviewPackDownloadUrl(EnvironmentReview $review, ManagedEnviron
|
||||
return null;
|
||||
}
|
||||
|
||||
$decision = app(CustomerOutputGate::class)->decisionForReviewPack($pack, $user);
|
||||
|
||||
if (! $decision->canStreamCustomerOutput) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(ReviewPackService::class)->generateDownloadUrl($pack, [
|
||||
'source_surface' => self::SOURCE_SURFACE,
|
||||
'review_id' => (int) $review->getKey(),
|
||||
@ -2670,13 +2677,13 @@ private function workspaceReadinessActions(
|
||||
'primary_icon' => 'heroicon-o-arrow-top-right-on-square',
|
||||
'secondary_label' => match ($state) {
|
||||
ReviewPackOutputReadiness::STATE_INTERNAL_REVIEW_PACKAGE_AVAILABLE => $downloadUrl !== null
|
||||
? __('localization.review.download_internal_review_pack')
|
||||
: null,
|
||||
default => $downloadUrl !== null
|
||||
? __('localization.review.download_review_pack_with_limitations')
|
||||
? __('localization.review.download_internal_preview')
|
||||
: null,
|
||||
default => null,
|
||||
},
|
||||
'secondary_url' => $downloadUrl,
|
||||
'secondary_url' => $state === ReviewPackOutputReadiness::STATE_INTERNAL_REVIEW_PACKAGE_AVAILABLE
|
||||
? $downloadUrl
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
@ -2699,7 +2706,7 @@ private function workspaceReadinessActions(
|
||||
'primary_url' => $reviewUrl ?? $downloadUrl,
|
||||
'primary_icon' => 'heroicon-o-arrow-top-right-on-square',
|
||||
'secondary_label' => $downloadUrl !== null
|
||||
? __('localization.review.download_internal_review_pack')
|
||||
? __('localization.review.download_internal_preview')
|
||||
: null,
|
||||
'secondary_url' => $downloadUrl,
|
||||
],
|
||||
@ -2716,10 +2723,8 @@ private function workspaceReadinessActions(
|
||||
'primary_label' => __('localization.review.review_output_limitations'),
|
||||
'primary_url' => $reviewUrl ?? $evidenceUrl ?? $downloadUrl,
|
||||
'primary_icon' => 'heroicon-o-arrow-top-right-on-square',
|
||||
'secondary_label' => $downloadUrl !== null
|
||||
? __('localization.review.download_review_pack_with_limitations')
|
||||
: null,
|
||||
'secondary_url' => $downloadUrl,
|
||||
'secondary_label' => null,
|
||||
'secondary_url' => null,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@ -36,6 +36,7 @@
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\ResolutionGuidance\Adapters\ReviewPackOutputResolutionAdapter;
|
||||
use App\Support\ReviewPacks\CustomerOutputGate;
|
||||
use App\Support\ReviewPacks\ReportProfileRegistry;
|
||||
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
|
||||
use App\Support\ReviewPackStatus;
|
||||
@ -1239,12 +1240,34 @@ public static function currentReviewPackDownloadUrlFor(EnvironmentReview $record
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(ReviewPackService::class)->generateDownloadUrl($pack, [
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
$decision = app(CustomerOutputGate::class)->decisionForReviewPack($pack, $user);
|
||||
$customerWorkspaceMode = static::isCustomerWorkspaceMode();
|
||||
|
||||
if ($customerWorkspaceMode && ! $decision->canStreamCustomerOutput) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $decision->canStreamCustomerOutput && ! $decision->canStreamInternalPreview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parameters = [
|
||||
'source_surface' => $customerWorkspaceMode
|
||||
? CustomerReviewWorkspace::SOURCE_SURFACE
|
||||
: 'environment_review_detail',
|
||||
'review_id' => (int) $record->getKey(),
|
||||
'tenant_filter_id' => request()->query('tenant_filter_id'),
|
||||
'interpretation_version' => $record->controlInterpretationVersion(),
|
||||
]);
|
||||
];
|
||||
|
||||
if (! $decision->canStreamCustomerOutput && $decision->canStreamInternalPreview) {
|
||||
$parameters[CustomerOutputGate::INTERNAL_PREVIEW_QUERY_KEY] = 1;
|
||||
}
|
||||
|
||||
return app(ReviewPackService::class)->generateDownloadUrl(
|
||||
$pack,
|
||||
array_filter($parameters, static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||
);
|
||||
}
|
||||
|
||||
public static function currentRenderedReportUrlFor(EnvironmentReview $record): ?string
|
||||
@ -1273,6 +1296,12 @@ public static function currentRenderedReportUrlFor(EnvironmentReview $record): ?
|
||||
return null;
|
||||
}
|
||||
|
||||
$decision = app(CustomerOutputGate::class)->decisionForReviewPack($pack, $user);
|
||||
|
||||
if (static::isCustomerWorkspaceMode() && ! $decision->canStreamCustomerOutput) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$guidance = ReviewPackOutputResolutionGuidance::fromReadiness(
|
||||
ReviewPackOutputResolutionGuidance::readinessForReview($record),
|
||||
);
|
||||
|
||||
@ -24,6 +24,7 @@
|
||||
use App\Support\Rbac\Actions\ResolvesUiActionContext;
|
||||
use App\Support\Rbac\Actions\UiActionContext;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\ReviewPacks\CustomerOutputGate;
|
||||
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
@ -448,21 +449,15 @@ public static function getPages(): array
|
||||
|
||||
public static function downloadActionLabelFor(ReviewPack $record): string
|
||||
{
|
||||
$review = $record->environmentReview;
|
||||
|
||||
if (! $review instanceof EnvironmentReview) {
|
||||
return __('localization.review.download_current_review_pack');
|
||||
}
|
||||
|
||||
$guidance = ReviewPackOutputResolutionGuidance::fromReadiness(
|
||||
ReviewPackOutputResolutionGuidance::readinessForReview($review),
|
||||
$actor = auth()->user();
|
||||
$decision = app(CustomerOutputGate::class)->decisionForReviewPack(
|
||||
$record,
|
||||
$actor instanceof User ? $actor : null,
|
||||
);
|
||||
|
||||
return match ((string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN)) {
|
||||
ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY => __('localization.review.download_customer_safe_review_pack'),
|
||||
ReviewPackOutputResolutionGuidance::STATE_INTERNAL_ONLY => __('localization.review.download_internal_review_pack'),
|
||||
default => __('localization.review.download_review_pack_with_limitations'),
|
||||
};
|
||||
return $decision->canStreamCustomerOutput
|
||||
? __('localization.review.download_customer_safe_review_pack')
|
||||
: __('localization.review.download_internal_preview');
|
||||
}
|
||||
|
||||
public static function isCustomerWorkspaceFlow(): bool
|
||||
|
||||
@ -18,6 +18,8 @@
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\ReviewPacks\CustomerOutputGate;
|
||||
use App\Support\ReviewPacks\CustomerOutputGateDecision;
|
||||
use App\Support\ReviewPacks\ReportProfileRegistry;
|
||||
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
|
||||
use App\Support\ReviewPackStatus;
|
||||
@ -42,12 +44,12 @@ protected function getHeaderActions(): array
|
||||
'review_id' => $this->record->environment_review_id,
|
||||
'tenant_filter_id' => request()->query('tenant_filter_id'),
|
||||
'interpretation_version' => $this->record->environmentReview?->controlInterpretationVersion(),
|
||||
], 'primary'),
|
||||
], 'primary', true),
|
||||
Actions\Action::make('download')
|
||||
->label(fn (): string => ReviewPackResource::downloadActionLabelFor($this->record))
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->record->status === ReviewPackStatus::Ready->value)
|
||||
->visible(fn (): bool => $this->customerOutputDecision()->canStreamCustomerOutput)
|
||||
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record, [
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
]))
|
||||
@ -101,6 +103,8 @@ protected function getHeaderActions(): array
|
||||
$regenerateAction->tooltip(fn (): ?string => ReviewPackResource::reviewPackGenerationActionTooltip($this->record->tenant));
|
||||
|
||||
$readyManagementReportPdf = $this->readyManagementReportPdf();
|
||||
$customerSafeManagementPdf = $readyManagementReportPdf instanceof StoredReport
|
||||
&& $this->customerOutputDecision()->canStreamCustomerOutput;
|
||||
|
||||
return [
|
||||
$this->openRenderedReportAction([
|
||||
@ -108,11 +112,11 @@ protected function getHeaderActions(): array
|
||||
'review_id' => $this->record->environment_review_id,
|
||||
'tenant_filter_id' => request()->query('tenant_filter_id'),
|
||||
'interpretation_version' => $this->record->environmentReview?->controlInterpretationVersion(),
|
||||
], $readyManagementReportPdf instanceof StoredReport ? 'gray' : 'primary'),
|
||||
$readyManagementReportPdf instanceof StoredReport
|
||||
], $customerSafeManagementPdf ? 'gray' : 'primary'),
|
||||
$customerSafeManagementPdf
|
||||
? $this->downloadManagementReportPdfAction($readyManagementReportPdf)
|
||||
: $this->downloadReviewPackAction(),
|
||||
Actions\ActionGroup::make($readyManagementReportPdf instanceof StoredReport
|
||||
Actions\ActionGroup::make($customerSafeManagementPdf
|
||||
? [
|
||||
$this->downloadReviewPackAction(),
|
||||
$regenerateAction,
|
||||
@ -219,8 +223,11 @@ private function downloadReviewPackAction(): Actions\Action
|
||||
->label(fn (): string => ReviewPackResource::downloadActionLabelFor($this->record))
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->record->status === ReviewPackStatus::Ready->value)
|
||||
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record))
|
||||
->visible(fn (): bool => $this->canDownloadReviewPack())
|
||||
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl(
|
||||
$this->record,
|
||||
$this->reviewPackDownloadParameters(),
|
||||
))
|
||||
->openUrlInNewTab();
|
||||
}
|
||||
|
||||
@ -338,13 +345,13 @@ private function managementReportPdfActionLabel(): string
|
||||
/**
|
||||
* @param array<string, scalar|null> $parameters
|
||||
*/
|
||||
private function openRenderedReportAction(array $parameters = [], string $color = 'primary'): Actions\Action
|
||||
private function openRenderedReportAction(array $parameters = [], string $color = 'primary', bool $requiresCustomerOutput = false): Actions\Action
|
||||
{
|
||||
return Actions\Action::make('open_rendered_report')
|
||||
->label(fn (): string => \App\Filament\Resources\EnvironmentReviewResource::renderedReportActionLabelFor($this->record->environmentReview))
|
||||
->icon('heroicon-o-document-text')
|
||||
->color($color)
|
||||
->visible(fn (): bool => $this->canOpenRenderedReport())
|
||||
->visible(fn (): bool => $this->canOpenRenderedReport($requiresCustomerOutput))
|
||||
->url(fn (): string => app(ReviewPackService::class)->generateRenderedReportUrl(
|
||||
$this->record,
|
||||
$this->renderedReportParameters($parameters),
|
||||
@ -379,7 +386,7 @@ private function renderedReportParameters(array $parameters): array
|
||||
return $parameters;
|
||||
}
|
||||
|
||||
private function canOpenRenderedReport(): bool
|
||||
private function canOpenRenderedReport(bool $requiresCustomerOutput = false): bool
|
||||
{
|
||||
/** @var ReviewPack $record */
|
||||
$record = $this->record;
|
||||
@ -396,6 +403,44 @@ private function canOpenRenderedReport(): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) ($record->environmentReview->current_export_review_pack_id ?? 0) === (int) $record->getKey();
|
||||
if ((int) ($record->environmentReview->current_export_review_pack_id ?? 0) !== (int) $record->getKey()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! $requiresCustomerOutput || $this->customerOutputDecision()->canStreamCustomerOutput;
|
||||
}
|
||||
|
||||
private function canDownloadReviewPack(): bool
|
||||
{
|
||||
$decision = $this->customerOutputDecision();
|
||||
|
||||
return $decision->canStreamCustomerOutput || $decision->canStreamInternalPreview;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, scalar|null>
|
||||
*/
|
||||
private function reviewPackDownloadParameters(): array
|
||||
{
|
||||
$decision = $this->customerOutputDecision();
|
||||
|
||||
if ($decision->canStreamCustomerOutput) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
CustomerOutputGate::INTERNAL_PREVIEW_QUERY_KEY => 1,
|
||||
'source_surface' => 'review_pack',
|
||||
];
|
||||
}
|
||||
|
||||
private function customerOutputDecision(): CustomerOutputGateDecision
|
||||
{
|
||||
$actor = auth()->user();
|
||||
|
||||
return app(CustomerOutputGate::class)->decisionForReviewPack(
|
||||
$this->record,
|
||||
$actor instanceof User ? $actor : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\ReviewPacks\CustomerOutputGate;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@ -59,6 +60,12 @@ public function __invoke(Request $request, StoredReport $storedReport): Streamed
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$gateDecision = app(CustomerOutputGate::class)->decisionForReviewPack($reviewPack, $user);
|
||||
|
||||
if (! $gateDecision->canStreamCustomerOutput) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$disk = Storage::disk((string) $storedReport->file_disk);
|
||||
|
||||
if (! $disk->exists((string) $storedReport->file_path)) {
|
||||
@ -77,6 +84,8 @@ public function __invoke(Request $request, StoredReport $storedReport): Streamed
|
||||
: null,
|
||||
'source_surface' => (string) $request->query('source_surface', 'review_pack'),
|
||||
'profile' => (string) $storedReport->profile,
|
||||
'customer_output_gate_state' => $gateDecision->state,
|
||||
'customer_output_guidance_state' => $gateDecision->guidanceState,
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\ReviewPacks\CustomerOutputGate;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@ -43,12 +44,23 @@ public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResp
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if (! filled($reviewPack->file_path) || ! filled($reviewPack->file_disk)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
$disk = Storage::disk($reviewPack->file_disk ?? 'exports');
|
||||
|
||||
if (! $disk->exists($reviewPack->file_path)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
$internalPreview = $request->boolean(CustomerOutputGate::INTERNAL_PREVIEW_QUERY_KEY);
|
||||
$gateDecision = app(CustomerOutputGate::class)->decisionForReviewPack($reviewPack, $user);
|
||||
|
||||
if (! $gateDecision->canStream($internalPreview)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $tenant->workspace,
|
||||
action: AuditActionId::ReviewPackDownloaded,
|
||||
@ -62,6 +74,9 @@ public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResp
|
||||
'review_id' => $request->query('review_id'),
|
||||
'tenant_filter_id' => $request->query('tenant_filter_id'),
|
||||
'interpretation_version' => $request->query('interpretation_version'),
|
||||
'download_mode' => $internalPreview ? 'internal_preview' : 'customer_output',
|
||||
'customer_output_gate_state' => $gateDecision->state,
|
||||
'customer_output_guidance_state' => $gateDecision->guidanceState,
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
|
||||
@ -14,6 +14,8 @@
|
||||
use App\Models\User;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\ReviewPacks\CustomerOutputGate;
|
||||
use App\Support\ReviewPacks\CustomerOutputGateDecision;
|
||||
use App\Support\ReviewPacks\ReportDisclosurePolicy;
|
||||
use App\Support\ReviewPacks\ReportProfileRegistry;
|
||||
use App\Support\ReviewPacks\ReportThemeResolver;
|
||||
@ -67,8 +69,19 @@ public function __invoke(Request $request, ReviewPack $reviewPack): Response
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
$gateDecision = app(CustomerOutputGate::class)->decisionForReviewPack($reviewPack, $user);
|
||||
$profile = $this->profileState($request, $gateDecision->guidanceState);
|
||||
|
||||
if ($this->isCustomerFacingReportRequest($request, $profile) && ! $gateDecision->canStreamCustomerOutput) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! (bool) ($profile['is_customer_facing'] ?? false) && ! $gateDecision->canStreamInternalPreview) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return response()->view('review-packs.rendered-report', [
|
||||
'report' => $this->reportState($request, $reviewPack, $review, $tenant),
|
||||
'report' => $this->reportState($request, $reviewPack, $review, $tenant, $gateDecision, $profile),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -80,14 +93,18 @@ private function reportState(
|
||||
ReviewPack $reviewPack,
|
||||
EnvironmentReview $review,
|
||||
ManagedEnvironment $tenant,
|
||||
CustomerOutputGateDecision $gateDecision,
|
||||
array $profile,
|
||||
): array {
|
||||
$summary = is_array($reviewPack->summary) ? $reviewPack->summary : [];
|
||||
$governancePackage = is_array($summary['governance_package'] ?? null) ? $summary['governance_package'] : [];
|
||||
$controlInterpretation = is_array($summary['control_interpretation'] ?? null) ? $summary['control_interpretation'] : [];
|
||||
$downloadUrl = $this->downloadUrl($request, $reviewPack, $review);
|
||||
$reviewUrl = $this->reviewUrl($request, $review, $tenant);
|
||||
$reviewPackUrl = $this->reviewPackUrl($request, $reviewPack, $tenant);
|
||||
$readiness = ReviewPackOutputResolutionGuidance::readinessForReview($review);
|
||||
$readiness = $gateDecision->readiness !== []
|
||||
? $gateDecision->readiness
|
||||
: ReviewPackOutputResolutionGuidance::readinessForReview($review);
|
||||
$downloadUrl = $this->downloadUrl($request, $reviewPack, $review, $gateDecision, $profile);
|
||||
$guidance = ReviewPackOutputResolutionGuidance::fromReadiness($readiness, [
|
||||
'download' => $downloadUrl,
|
||||
'review' => $reviewUrl,
|
||||
@ -98,7 +115,6 @@ private function reportState(
|
||||
$state = (string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN);
|
||||
$limitations = $this->managementLimitations(is_array($guidance['limitations'] ?? null) ? $guidance['limitations'] : []);
|
||||
$evidenceBasis = $this->evidenceBasisState($review);
|
||||
$profile = $this->profileState($request, $state);
|
||||
$nonCertificationDisclosure = $this->plainText(
|
||||
$controlInterpretation['non_certification_disclosure'] ?? null,
|
||||
__('localization.review.non_certification_disclosure_text'),
|
||||
@ -399,8 +415,7 @@ private function downloadLabel(string $state): string
|
||||
{
|
||||
return match ($state) {
|
||||
ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY => __('localization.review.download_customer_safe_review_pack'),
|
||||
ReviewPackOutputResolutionGuidance::STATE_INTERNAL_ONLY => __('localization.review.download_internal_review_pack'),
|
||||
default => __('localization.review.download_review_pack_with_limitations'),
|
||||
default => __('localization.review.download_internal_preview'),
|
||||
};
|
||||
}
|
||||
|
||||
@ -502,15 +517,37 @@ private function sectionEntries(array $renderPayload, bool $isControlInterpretat
|
||||
->all();
|
||||
}
|
||||
|
||||
private function downloadUrl(Request $request, ReviewPack $reviewPack, EnvironmentReview $review): ?string
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $profile
|
||||
*/
|
||||
private function downloadUrl(
|
||||
Request $request,
|
||||
ReviewPack $reviewPack,
|
||||
EnvironmentReview $review,
|
||||
CustomerOutputGateDecision $gateDecision,
|
||||
array $profile,
|
||||
): ?string {
|
||||
if (! filled($reviewPack->file_path) || ! filled($reviewPack->file_disk)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $gateDecision->canStreamCustomerOutput && ! $gateDecision->canStreamInternalPreview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $gateDecision->canStreamCustomerOutput && (bool) ($profile['is_customer_facing'] ?? false)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parameters = $this->routeParameters($request, $review);
|
||||
|
||||
if (! $gateDecision->canStreamCustomerOutput && $gateDecision->canStreamInternalPreview) {
|
||||
$parameters[CustomerOutputGate::INTERNAL_PREVIEW_QUERY_KEY] = 1;
|
||||
}
|
||||
|
||||
return app(ReviewPackService::class)->generateDownloadUrl(
|
||||
$reviewPack,
|
||||
$this->routeParameters($request, $review),
|
||||
$parameters,
|
||||
);
|
||||
}
|
||||
|
||||
@ -621,6 +658,15 @@ private function isCustomerWorkspaceReportRequest(Request $request): bool
|
||||
|| (string) $request->query('source_surface') === CustomerReviewWorkspace::SOURCE_SURFACE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $profile
|
||||
*/
|
||||
private function isCustomerFacingReportRequest(Request $request, array $profile): bool
|
||||
{
|
||||
return $this->isCustomerWorkspaceReportRequest($request)
|
||||
|| (bool) ($profile['is_customer_facing'] ?? false);
|
||||
}
|
||||
|
||||
private function plainText(mixed $value, string $fallback): string
|
||||
{
|
||||
if (! is_scalar($value) && $value !== null) {
|
||||
|
||||
134
apps/platform/app/Support/ReviewPacks/CustomerOutputGate.php
Normal file
134
apps/platform/app/Support/ReviewPacks/CustomerOutputGate.php
Normal file
@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\ReviewPacks;
|
||||
|
||||
use App\Models\EnvironmentReview;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\ReviewPackStatus;
|
||||
|
||||
final class CustomerOutputGate
|
||||
{
|
||||
public const string INTERNAL_PREVIEW_QUERY_KEY = 'internal_preview';
|
||||
|
||||
public const string STATE_READY = 'ready';
|
||||
|
||||
public const string STATE_NEEDS_ATTENTION = 'needs_attention';
|
||||
|
||||
public const string STATE_BLOCKED = 'blocked';
|
||||
|
||||
public const string STATE_INTERNAL_ONLY = 'internal_only';
|
||||
|
||||
public const string STATE_NOT_AVAILABLE = 'not_available';
|
||||
|
||||
public const string STATE_EXPIRED = 'expired';
|
||||
|
||||
public const string STATE_UNKNOWN = 'unknown';
|
||||
|
||||
public function decisionForReviewPack(ReviewPack $reviewPack, ?User $actor = null): CustomerOutputGateDecision
|
||||
{
|
||||
$reviewPack->loadMissing([
|
||||
'tenant',
|
||||
'environmentReview.evidenceSnapshot',
|
||||
'environmentReview.currentExportReviewPack',
|
||||
'environmentReview.sections',
|
||||
]);
|
||||
|
||||
$tenant = $reviewPack->tenant;
|
||||
$review = $reviewPack->environmentReview;
|
||||
$hasReadyArtifact = $this->hasReadyArtifact($reviewPack);
|
||||
$canViewReviewPack = $tenant instanceof ManagedEnvironment
|
||||
&& $actor instanceof User
|
||||
&& $actor->can(Capabilities::REVIEW_PACK_VIEW, $tenant);
|
||||
$canManageReviewPack = $tenant instanceof ManagedEnvironment
|
||||
&& $actor instanceof User
|
||||
&& $actor->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
|
||||
|
||||
if ($this->isExpired($reviewPack)) {
|
||||
return new CustomerOutputGateDecision(
|
||||
state: self::STATE_EXPIRED,
|
||||
guidanceState: ReviewPackOutputResolutionGuidance::STATE_EXPORT_NOT_READY,
|
||||
hasReadyArtifact: false,
|
||||
canStreamCustomerOutput: false,
|
||||
canStreamInternalPreview: false,
|
||||
reason: __('localization.review.expired'),
|
||||
);
|
||||
}
|
||||
|
||||
if (! $hasReadyArtifact) {
|
||||
return new CustomerOutputGateDecision(
|
||||
state: self::STATE_NOT_AVAILABLE,
|
||||
guidanceState: ReviewPackOutputResolutionGuidance::STATE_EXPORT_NOT_READY,
|
||||
hasReadyArtifact: false,
|
||||
canStreamCustomerOutput: false,
|
||||
canStreamInternalPreview: false,
|
||||
reason: __('localization.review.export_not_ready'),
|
||||
);
|
||||
}
|
||||
|
||||
if (! $review instanceof EnvironmentReview || ! $this->isCurrentReviewExport($reviewPack, $review)) {
|
||||
return new CustomerOutputGateDecision(
|
||||
state: self::STATE_UNKNOWN,
|
||||
guidanceState: ReviewPackOutputResolutionGuidance::STATE_UNKNOWN,
|
||||
hasReadyArtifact: true,
|
||||
canStreamCustomerOutput: false,
|
||||
canStreamInternalPreview: $canManageReviewPack,
|
||||
reason: __('localization.review.requires_review'),
|
||||
);
|
||||
}
|
||||
|
||||
$readiness = ReviewPackOutputResolutionGuidance::readinessForReview($review);
|
||||
$guidance = ReviewPackOutputResolutionGuidance::fromReadiness($readiness);
|
||||
$guidanceState = (string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN);
|
||||
$canStreamCustomerOutput = $guidanceState === ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY
|
||||
&& $canViewReviewPack;
|
||||
|
||||
return new CustomerOutputGateDecision(
|
||||
state: $this->stateForGuidance($guidanceState),
|
||||
guidanceState: $guidanceState,
|
||||
hasReadyArtifact: true,
|
||||
canStreamCustomerOutput: $canStreamCustomerOutput,
|
||||
canStreamInternalPreview: $canManageReviewPack,
|
||||
reason: (string) ($guidance['primary_reason'] ?? __('localization.review.requires_review')),
|
||||
readiness: $readiness,
|
||||
guidance: $guidance,
|
||||
);
|
||||
}
|
||||
|
||||
private function hasReadyArtifact(ReviewPack $reviewPack): bool
|
||||
{
|
||||
if ($reviewPack->status !== ReviewPackStatus::Ready->value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return filled($reviewPack->file_path) && filled($reviewPack->file_disk);
|
||||
}
|
||||
|
||||
private function isExpired(ReviewPack $reviewPack): bool
|
||||
{
|
||||
return $reviewPack->status === ReviewPackStatus::Expired->value
|
||||
|| ($reviewPack->expires_at !== null && $reviewPack->expires_at->isPast());
|
||||
}
|
||||
|
||||
private function isCurrentReviewExport(ReviewPack $reviewPack, EnvironmentReview $review): bool
|
||||
{
|
||||
return (int) $review->managed_environment_id === (int) $reviewPack->managed_environment_id
|
||||
&& (int) ($review->current_export_review_pack_id ?? 0) === (int) $reviewPack->getKey();
|
||||
}
|
||||
|
||||
private function stateForGuidance(string $guidanceState): string
|
||||
{
|
||||
return match ($guidanceState) {
|
||||
ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY => self::STATE_READY,
|
||||
ReviewPackOutputResolutionGuidance::STATE_PUBLICATION_BLOCKED => self::STATE_BLOCKED,
|
||||
ReviewPackOutputResolutionGuidance::STATE_INTERNAL_ONLY => self::STATE_INTERNAL_ONLY,
|
||||
ReviewPackOutputResolutionGuidance::STATE_EXPORT_NOT_READY => self::STATE_NOT_AVAILABLE,
|
||||
ReviewPackOutputResolutionGuidance::STATE_PUBLISHED_WITH_LIMITATIONS => self::STATE_NEEDS_ATTENTION,
|
||||
default => self::STATE_UNKNOWN,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\ReviewPacks;
|
||||
|
||||
final class CustomerOutputGateDecision
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $readiness
|
||||
* @param array<string, mixed> $guidance
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $state,
|
||||
public readonly string $guidanceState,
|
||||
public readonly bool $hasReadyArtifact,
|
||||
public readonly bool $canStreamCustomerOutput,
|
||||
public readonly bool $canStreamInternalPreview,
|
||||
public readonly string $reason,
|
||||
public readonly array $readiness = [],
|
||||
public readonly array $guidance = [],
|
||||
) {}
|
||||
|
||||
public function canStream(bool $internalPreview): bool
|
||||
{
|
||||
return $internalPreview
|
||||
? $this->canStreamInternalPreview
|
||||
: $this->canStreamCustomerOutput;
|
||||
}
|
||||
}
|
||||
@ -403,7 +403,7 @@ private static function secondaryActions(string $state, ?array $primaryAction, a
|
||||
self::action('open_review', $urls['review'] ?? null),
|
||||
],
|
||||
self::STATE_INTERNAL_ONLY => [
|
||||
self::action('download_internal_review_pack', $urls['download'] ?? null),
|
||||
self::action('download_internal_preview', $urls['download'] ?? null),
|
||||
self::action('open_review', $urls['review'] ?? null),
|
||||
],
|
||||
self::STATE_EXPORT_NOT_READY => [
|
||||
@ -411,12 +411,12 @@ private static function secondaryActions(string $state, ?array $primaryAction, a
|
||||
self::action('open_review', $urls['review'] ?? null),
|
||||
],
|
||||
self::STATE_PUBLICATION_BLOCKED => [
|
||||
self::action('download_review_pack_with_limitations', $urls['download'] ?? null),
|
||||
self::action('download_internal_preview', $urls['download'] ?? null),
|
||||
self::action('open_evidence_basis', $urls['evidence'] ?? null),
|
||||
self::action('open_operation_proof', $urls['operation'] ?? null),
|
||||
],
|
||||
self::STATE_PUBLISHED_WITH_LIMITATIONS => [
|
||||
self::action('download_review_pack_with_limitations', $urls['download'] ?? null),
|
||||
self::action('download_internal_preview', $urls['download'] ?? null),
|
||||
self::action('open_review', $urls['review'] ?? null),
|
||||
self::action('open_evidence_basis', $urls['evidence'] ?? null),
|
||||
],
|
||||
@ -441,7 +441,7 @@ private static function secondaryActions(string $state, ?array $primaryAction, a
|
||||
private static function primaryActionUrl(string $actionKey, array $urls): ?string
|
||||
{
|
||||
return match ($actionKey) {
|
||||
'download_customer_safe_review_pack', 'download_internal_review_pack', 'download_review_pack_with_limitations' => $urls['download'] ?? null,
|
||||
'download_customer_safe_review_pack', 'download_internal_preview' => $urls['download'] ?? null,
|
||||
'open_baseline_subject_resolution' => $urls['evidence'] ?? $urls['review'] ?? null,
|
||||
'open_evidence_basis' => $urls['evidence'] ?? $urls['review'] ?? null,
|
||||
'review_section_limitations', 'resolve_review_blockers', 'review_output_limitations', 'review_pii_redaction_state', 'open_review' => $urls['review'] ?? $urls['evidence'] ?? $urls['download'] ?? null,
|
||||
@ -459,8 +459,7 @@ private static function action(string $actionKey, ?string $url): ?array
|
||||
'key' => $actionKey,
|
||||
'label' => match ($actionKey) {
|
||||
'download_customer_safe_review_pack' => __('localization.review.download_customer_safe_review_pack'),
|
||||
'download_internal_review_pack' => __('localization.review.download_internal_review_pack'),
|
||||
'download_review_pack_with_limitations' => __('localization.review.download_review_pack_with_limitations'),
|
||||
'download_internal_preview' => __('localization.review.download_internal_preview'),
|
||||
'open_evidence_basis' => __('localization.review.open_evidence_basis'),
|
||||
'review_section_limitations' => __('localization.review.review_section_limitations'),
|
||||
'review_pii_redaction_state' => __('localization.review.review_pii_redaction_state'),
|
||||
@ -609,8 +608,7 @@ private static function qualifiedDownloadLabel(string $state): string
|
||||
{
|
||||
return match ($state) {
|
||||
self::STATE_CUSTOMER_SAFE_READY => __('localization.review.download_customer_safe_review_pack'),
|
||||
self::STATE_INTERNAL_ONLY => __('localization.review.download_internal_review_pack'),
|
||||
default => __('localization.review.download_review_pack_with_limitations'),
|
||||
default => __('localization.review.download_internal_preview'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -761,8 +761,7 @@
|
||||
'primary_action' => 'Primäre Aktion',
|
||||
'download_review_pack' => 'Review-Pack herunterladen',
|
||||
'download_customer_safe_review_pack' => 'Kundensicheres Review-Paket herunterladen',
|
||||
'download_review_pack_with_limitations' => 'Review-Paket mit Einschränkungen herunterladen',
|
||||
'download_internal_review_pack' => 'Internes Review-Paket herunterladen',
|
||||
'download_internal_preview' => 'Interne Vorschau herunterladen',
|
||||
'download_current_review_pack' => 'Aktuelles Review-Pack herunterladen',
|
||||
'download_governance_package' => 'Governance-Paket herunterladen',
|
||||
'download_management_report_pdf' => 'Management-PDF herunterladen',
|
||||
|
||||
@ -761,8 +761,7 @@
|
||||
'primary_action' => 'Primary action',
|
||||
'download_review_pack' => 'Download review pack',
|
||||
'download_customer_safe_review_pack' => 'Download customer-safe review pack',
|
||||
'download_review_pack_with_limitations' => 'Download review pack with limitations',
|
||||
'download_internal_review_pack' => 'Download internal review pack',
|
||||
'download_internal_preview' => 'Download internal preview',
|
||||
'download_current_review_pack' => 'Download current review pack',
|
||||
'download_governance_package' => 'Download governance package',
|
||||
'download_management_report_pdf' => 'Download management PDF',
|
||||
|
||||
@ -381,7 +381,7 @@
|
||||
|
||||
@if (filled($report['download_url'] ?? null))
|
||||
<a class="app-action" href="{{ $report['download_url'] }}" target="_blank" rel="noreferrer">
|
||||
{{ $report['download_label'] ?? __('localization.review.download_review_pack_with_limitations') }}
|
||||
{{ $report['download_label'] ?? __('localization.review.download_internal_preview') }}
|
||||
</a>
|
||||
@endif
|
||||
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
it('keeps deprecated customer-output limited download vocabulary out of product surfaces', function (): void {
|
||||
$forbiddenTerms = [
|
||||
'download_review_pack_with_limitations',
|
||||
'Download review pack with limitations',
|
||||
'Download with limitations',
|
||||
];
|
||||
|
||||
$roots = [
|
||||
app_path(),
|
||||
base_path('routes'),
|
||||
resource_path(),
|
||||
base_path('lang'),
|
||||
base_path('tests'),
|
||||
];
|
||||
$self = realpath(__FILE__);
|
||||
$violations = [];
|
||||
|
||||
foreach ($roots as $root) {
|
||||
if (! is_dir($root)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file instanceof SplFileInfo || ! $file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $file->getRealPath();
|
||||
|
||||
if (! is_string($path) || $path === $self || ! preg_match('/\.(blade\.php|php|js|ts|vue|json)$/', $path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contents = file_get_contents($path);
|
||||
|
||||
if (! is_string($contents)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($forbiddenTerms as $term) {
|
||||
if (str_contains($contents, $term)) {
|
||||
$violations[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $path).': '.$term;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect($violations)->toBeEmpty();
|
||||
});
|
||||
@ -2,16 +2,17 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\EnvironmentReviewResource;
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Resources\EnvironmentReviewResource;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\EnvironmentReviewCompletenessState;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\EnvironmentReviewStatus;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
|
||||
use App\Support\ReviewPacks\ReportProfileRegistry;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@ -303,20 +304,16 @@
|
||||
'interpretation_version' => $limitedReview->controlInterpretationVersion(),
|
||||
]);
|
||||
|
||||
$limitedReportPage = visit($limitedReportUrl)
|
||||
visit($limitedReportUrl)
|
||||
->resize(1280, 1440)
|
||||
->waitForText('Bericht mit Einschränkungen')
|
||||
->assertSee('Nicht extern weitergeben, bevor der Bericht geprüft wurde.')
|
||||
->assertSee('Output-Einschränkungen')
|
||||
->assertSee('Die Evidence-Basis ist unvollständig')
|
||||
->assertScript('document.body.innerText.includes("403") || document.body.innerText.includes("Forbidden")', true)
|
||||
->assertDontSee('Bericht mit Einschränkungen')
|
||||
->assertDontSee('Kundensicherer Bericht bereit')
|
||||
->assertDontSee('localization.')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
$limitedReportPage->screenshot(true, spec356BrowserScreenshotName('report-with-limitations'));
|
||||
spec356CopyBrowserScreenshot('report-with-limitations');
|
||||
|
||||
$internalReportUrl = app(ReviewPackService::class)->generateRenderedReportUrl($internalPack, [
|
||||
$customerWorkspaceInternalReportUrl = app(ReviewPackService::class)->generateRenderedReportUrl($internalPack, [
|
||||
CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1,
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
'tenant_filter_id' => (int) $internalEnvironment->getKey(),
|
||||
@ -324,12 +321,28 @@
|
||||
'interpretation_version' => $internalReview->controlInterpretationVersion(),
|
||||
]);
|
||||
|
||||
visit($customerWorkspaceInternalReportUrl)
|
||||
->resize(1280, 1440)
|
||||
->assertScript('document.body.innerText.includes("403") || document.body.innerText.includes("Forbidden")', true)
|
||||
->assertDontSee('Interner Bericht mit Einschränkungen')
|
||||
->assertDontSee('Kundensicherer Bericht bereit')
|
||||
->assertDontSee('localization.')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
$internalReportUrl = app(ReviewPackService::class)->generateRenderedReportUrl($internalPack, [
|
||||
'source_surface' => 'review_pack',
|
||||
'review_id' => (int) $internalReview->getKey(),
|
||||
'interpretation_version' => $internalReview->controlInterpretationVersion(),
|
||||
ReportProfileRegistry::QUERY_PARAMETER => ReportProfileRegistry::INTERNAL_MSP_REVIEW,
|
||||
]);
|
||||
|
||||
$internalReportPage = visit($internalReportUrl)
|
||||
->resize(1280, 1440)
|
||||
->waitForText('Interner Bericht mit Einschränkungen')
|
||||
->assertSee('Nicht extern weitergeben, bevor der Bericht geprüft wurde.')
|
||||
->assertSee('interne oder PII-tragende Details')
|
||||
->assertSee('Internes Review-Paket herunterladen')
|
||||
->assertSee('Interne Vorschau herunterladen')
|
||||
->assertDontSee('Kundensicherer Bericht bereit')
|
||||
->assertDontSee('localization.')
|
||||
->assertNoJavaScriptErrors()
|
||||
|
||||
@ -57,7 +57,7 @@
|
||||
'next_action' => 'Review the evidence basis before relying on the decision summary.',
|
||||
'entries' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'review-packs/spec342-browser-evidence-incomplete.zip',
|
||||
normalizeOutputReadiness: false,
|
||||
@ -111,11 +111,11 @@
|
||||
->waitForText('Output not customer-ready')
|
||||
->assertSee('Review blockers are still recorded for this output.')
|
||||
->assertSee('Needs review')
|
||||
->assertSee('Download review pack with limitations')
|
||||
->assertDontSee('Download internal preview')
|
||||
->assertSee('Review consumption flow')
|
||||
->assertScript('document.querySelectorAll("[data-testid=\"customer-review-readiness-step\"]").length === 6', true)
|
||||
->assertScript('document.querySelector("[data-step-label=\"Review pack\"]")?.dataset.stepState === "Available"', true)
|
||||
->assertScript('document.querySelector("[data-step-label=\"Customer-safe output\"]")?.dataset.stepState === "Needs review"', true)
|
||||
->assertScript('document.querySelector("[data-step-label=\"Customer-safe output\"]")?.dataset.stepState === "Not ready"', true)
|
||||
->assertScript('document.querySelector("[data-testid=\"customer-review-diagnostics\"]")?.open === false', true)
|
||||
->assertDontSee('raw payload should stay hidden')
|
||||
->assertDontSee('provider response should stay hidden')
|
||||
@ -132,8 +132,8 @@
|
||||
->assertSee('Download customer-safe review pack')
|
||||
->assertSee('Review pack state')
|
||||
->assertSee('Export ready')
|
||||
->assertSee('Operation proof')
|
||||
->assertSee('Spec342 Browser Operator')
|
||||
->assertDontSee('Operation proof')
|
||||
->assertDontSee('Spec342 Browser Operator')
|
||||
->assertSee('No open findings require customer action.')
|
||||
->assertScript('document.querySelectorAll("[data-testid=\"customer-review-primary-action\"]").length === 1', true)
|
||||
->assertScript('document.querySelector("[data-testid=\"customer-review-evidence-path-panel\"]")?.innerText.includes("Download review pack") === false', true)
|
||||
@ -166,8 +166,8 @@
|
||||
->assertSee('Do not treat this review as share-ready until open findings are resolved, accepted, or explicitly reviewed.')
|
||||
->assertSee('High impact')
|
||||
->assertSee('Open review')
|
||||
->assertSee('Download review pack with limitations')
|
||||
->assertScript('document.querySelector("[data-testid=\"customer-review-decision-card\"]")?.innerText.includes("Download review pack with limitations") === true', true)
|
||||
->assertDontSee('Download internal preview')
|
||||
->assertScript('document.querySelector("[data-testid=\"customer-review-decision-card\"]")?.innerText.includes("Download internal preview") === false', true)
|
||||
->assertScript('document.querySelector("[data-testid=\"customer-review-evidence-path-panel\"]")?.innerText.includes("Download review pack") === false', true)
|
||||
->assertScript('document.querySelector("[data-step-label=\"Findings triaged\"]")?.dataset.stepState === "Needs review"', true)
|
||||
->assertScript('document.querySelector("[data-step-label=\"Findings triaged\"]")?.dataset.stepCurrent === "true"', true)
|
||||
@ -188,8 +188,8 @@
|
||||
->assertSee('Customer-approved maintenance window.')
|
||||
->assertSee('Review date not recorded')
|
||||
->assertSee('Accepted risk requires customer awareness.')
|
||||
->assertSee('Download review pack with limitations')
|
||||
->assertScript('document.querySelector("[data-testid=\"customer-review-decision-card\"]")?.innerText.includes("Download review pack with limitations") === true', true)
|
||||
->assertDontSee('Download internal preview')
|
||||
->assertScript('document.querySelector("[data-testid=\"customer-review-decision-card\"]")?.innerText.includes("Download internal preview") === false', true)
|
||||
->assertScript('document.querySelector("[data-step-label=\"Accepted risks reviewed\"]")?.dataset.stepCurrent === "true"', true)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
@ -94,7 +94,7 @@
|
||||
$page = visit(CustomerReviewWorkspace::environmentFilterUrl($limitedEnvironment))
|
||||
->waitForText('Output not customer-ready')
|
||||
->assertSee('Review blockers are still recorded for this output.')
|
||||
->assertSee('Download review pack with limitations')
|
||||
->assertDontSee('Download internal preview')
|
||||
->assertSee('Requires review')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
@ -105,7 +105,8 @@
|
||||
->waitForText('Internal review package available')
|
||||
->assertSee('Contains PII')
|
||||
->assertSee('Review PII/redaction state')
|
||||
->assertSee('Download internal review pack')
|
||||
->assertDontSee('Download internal review pack')
|
||||
->assertDontSee('Download internal preview')
|
||||
->assertSee('Internal only')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
@ -87,7 +87,7 @@
|
||||
->waitForText('Output not customer-ready')
|
||||
->assertSee('Create next review')
|
||||
->assertSee('Inspect review blockers')
|
||||
->assertSee('Download review pack with limitations')
|
||||
->assertDontSee('Download internal preview')
|
||||
->assertSee('Create the next review cycle from the latest eligible evidence basis.')
|
||||
->assertScript('Array.from(document.querySelectorAll("[data-testid=\"customer-review-decision-card\"] [data-testid=\"customer-review-secondary-action\"]")).some((element) => element.innerText.includes("Open review")) === false', true)
|
||||
->assertSee('Requires review')
|
||||
@ -101,7 +101,8 @@
|
||||
$page = visit(CustomerReviewWorkspace::environmentFilterUrl($internalEnvironment))
|
||||
->waitForText('Internal review package available')
|
||||
->assertSee('Review PII/redaction state')
|
||||
->assertSee('Download internal review pack')
|
||||
->assertDontSee('Download internal review pack')
|
||||
->assertDontSee('Download internal preview')
|
||||
->assertSee('Internal only')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
@ -80,14 +80,10 @@
|
||||
|
||||
visit(spec366BrowserRenderedReportUrl($limitedPack, $limitedReview, ReportProfileRegistry::CUSTOMER_EXECUTIVE))
|
||||
->resize(1280, 1440)
|
||||
->waitForText(__('localization.review.report_state_with_limitations'))
|
||||
->assertSee(__('localization.review.report_external_sharing_warning'))
|
||||
->assertSee(__('localization.review.output_limitations'))
|
||||
->assertScript('document.querySelector("[data-testid=\"rendered-report-canvas\"]").dataset.layoutMode === "executive"', true)
|
||||
->assertScript('document.body.innerText.includes("403") || document.body.innerText.includes("Forbidden")', true)
|
||||
->assertDontSee(__('localization.review.report_state_with_limitations'))
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->screenshot(true, spec366BrowserScreenshotName('02-customer-executive-limited-report'));
|
||||
spec366CopyBrowserScreenshot('02-customer-executive-limited-report');
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
visit(spec366BrowserRenderedReportUrl($internalPack, $internalReview, ReportProfileRegistry::INTERNAL_MSP_REVIEW))
|
||||
->resize(1280, 1440)
|
||||
|
||||
@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\User;
|
||||
use App\Support\EnvironmentReviewStatus;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
pest()->browser()->timeout(60_000);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
it('Spec392 smokes customer-output gating and internal-preview separation', function (): void {
|
||||
[$user, $readyEnvironment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
||||
$readyEnvironment->forceFill(['name' => 'Spec392 Browser Ready'])->save();
|
||||
$blockedEnvironment = spec392BrowserEnvironmentFor($user, $readyEnvironment, 'Spec392 Browser Blocked');
|
||||
|
||||
spec392BrowserCreatePublishedReviewWithPack(
|
||||
environment: $readyEnvironment,
|
||||
user: $user,
|
||||
customerSafeReady: true,
|
||||
filePath: 'review-packs/spec392-browser-ready.zip',
|
||||
);
|
||||
spec392BrowserCreatePublishedReviewWithPack(
|
||||
environment: $blockedEnvironment,
|
||||
user: $user,
|
||||
customerSafeReady: false,
|
||||
filePath: 'review-packs/spec392-browser-blocked.zip',
|
||||
);
|
||||
|
||||
spec392AuthenticateBrowser($this, $user, $readyEnvironment);
|
||||
|
||||
visit(CustomerReviewWorkspace::environmentFilterUrl($readyEnvironment))
|
||||
->resize(1236, 900)
|
||||
->waitForText('Customer-safe review pack ready')
|
||||
->assertSee('Download customer-safe review pack')
|
||||
->assertDontSee('Download internal preview')
|
||||
->assertScript('document.querySelector("[data-testid=\"customer-review-primary-action\"]")?.innerText.includes("Download customer-safe review pack") === true', true)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
visit(CustomerReviewWorkspace::environmentFilterUrl($blockedEnvironment))
|
||||
->resize(1236, 900)
|
||||
->waitForText('Output not customer-ready')
|
||||
->assertSee('Requires review')
|
||||
->assertDontSee('Download internal preview')
|
||||
->assertScript('document.querySelector("[data-testid=\"customer-review-decision-card\"]")?.innerText.includes("Download internal preview") === false', true)
|
||||
->assertScript('document.querySelector("[data-testid=\"customer-review-primary-action\"]")?.innerText.includes("Download customer-safe review pack") === false', true)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
});
|
||||
|
||||
function spec392BrowserEnvironmentFor(User $user, ManagedEnvironment $baseEnvironment, string $name): ManagedEnvironment
|
||||
{
|
||||
$environment = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $baseEnvironment->workspace_id,
|
||||
'name' => $name,
|
||||
]);
|
||||
|
||||
createUserWithTenant(tenant: $environment, user: $user, role: 'owner', workspaceRole: 'manager');
|
||||
|
||||
return $environment;
|
||||
}
|
||||
|
||||
function spec392BrowserCreatePublishedReviewWithPack(
|
||||
ManagedEnvironment $environment,
|
||||
User $user,
|
||||
bool $customerSafeReady,
|
||||
string $filePath,
|
||||
): array {
|
||||
$snapshot = $customerSafeReady
|
||||
? seedEnvironmentReviewEvidence($environment, findingCount: 0, driftCount: 0)
|
||||
: seedPartialEnvironmentReviewEvidence($environment, findingCount: 0, driftCount: 0);
|
||||
$review = composeEnvironmentReviewForTest($environment, $user, $snapshot);
|
||||
$review->forceFill([
|
||||
'status' => EnvironmentReviewStatus::Published->value,
|
||||
'published_at' => now()->subMinutes(10),
|
||||
'published_by_user_id' => (int) $user->getKey(),
|
||||
])->save();
|
||||
|
||||
if ($customerSafeReady) {
|
||||
$review = markEnvironmentReviewCustomerSafeReady($review);
|
||||
}
|
||||
|
||||
Storage::disk('exports')->put($filePath, 'Spec392 browser review pack');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'environment_review_id' => (int) $review->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'status' => ReviewPackStatus::Ready->value,
|
||||
'options' => [
|
||||
'include_pii' => false,
|
||||
'include_operations' => true,
|
||||
],
|
||||
'file_path' => $filePath,
|
||||
'file_disk' => 'exports',
|
||||
'generated_at' => now()->subMinutes(5),
|
||||
'expires_at' => now()->addDay(),
|
||||
]);
|
||||
|
||||
$review->forceFill([
|
||||
'current_export_review_pack_id' => (int) $pack->getKey(),
|
||||
])->save();
|
||||
|
||||
return [$review->refresh(), $pack->refresh()];
|
||||
}
|
||||
|
||||
function spec392AuthenticateBrowser(mixed $test, User $user, ManagedEnvironment $environment): void
|
||||
{
|
||||
$workspaceId = (int) $environment->workspace_id;
|
||||
|
||||
$test->actingAs($user)->withSession([
|
||||
WorkspaceContext::SESSION_KEY => $workspaceId,
|
||||
WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [
|
||||
(string) $workspaceId => (int) $environment->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
|
||||
session()->put(WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY, [
|
||||
(string) $workspaceId => (int) $environment->getKey(),
|
||||
]);
|
||||
|
||||
setAdminPanelContext($environment);
|
||||
}
|
||||
@ -89,8 +89,8 @@
|
||||
->assertSee('Review pack state')
|
||||
->assertSee('Export ready')
|
||||
->assertSee('Download customer-safe review pack')
|
||||
->assertSee('Operation proof')
|
||||
->assertSee('Spec342 Operator')
|
||||
->assertDontSee('Operation proof')
|
||||
->assertDontSee('Spec342 Operator')
|
||||
->assertDontSee('Auditor-ready')
|
||||
->assertDontSee('environment is healthy')
|
||||
->assertDontSee('compliant');
|
||||
@ -156,8 +156,8 @@
|
||||
->assertSee('Diagnostics')
|
||||
->assertSee('Collapsed')
|
||||
->assertDontSee('Ready to share')
|
||||
->assertSee('Export ready')
|
||||
->assertSee('Download review pack with limitations')
|
||||
->assertSee('Available')
|
||||
->assertDontSee('Download internal preview')
|
||||
->assertDontSee('raw payload should stay hidden')
|
||||
->assertDontSee('provider response should stay hidden')
|
||||
->assertDontSee('stack trace should stay hidden')
|
||||
@ -207,7 +207,7 @@
|
||||
->assertSee('Do not treat this review as share-ready until open findings are resolved, accepted, or explicitly reviewed.')
|
||||
->assertSee('Open review')
|
||||
->assertSee('Review pack state')
|
||||
->assertSee('Download review pack with limitations')
|
||||
->assertDontSee('Download internal preview')
|
||||
->assertDontSee('TenantPilot recorded an access, scope, or configuration issue');
|
||||
|
||||
$html = $component->html();
|
||||
|
||||
@ -75,7 +75,7 @@
|
||||
->assertSee('Output not customer-ready')
|
||||
->assertSee('Review blockers are still recorded for this output.')
|
||||
->assertSee('Requires review')
|
||||
->assertSee('Download review pack with limitations')
|
||||
->assertDontSee('Download internal preview')
|
||||
->assertDontSee('Ready to share');
|
||||
});
|
||||
|
||||
@ -109,7 +109,8 @@
|
||||
->assertSee('Internal only')
|
||||
->assertSee('Contains PII')
|
||||
->assertSee('Review PII/redaction state')
|
||||
->assertSee('Download internal review pack')
|
||||
->assertDontSee('Download internal review pack')
|
||||
->assertDontSee('Download internal preview')
|
||||
->assertDontSee('Customer-safe review pack ready');
|
||||
});
|
||||
|
||||
|
||||
@ -113,7 +113,8 @@
|
||||
->assertSee('Environment filter:')
|
||||
->assertSee('Spec349 Internal')
|
||||
->assertSee('Internal review package available')
|
||||
->assertSee('Download internal review pack')
|
||||
->assertDontSee('Download internal review pack')
|
||||
->assertDontSee('Download internal preview')
|
||||
->assertDontSee('Download governance package')
|
||||
->assertDontSee('Ready to share');
|
||||
});
|
||||
|
||||
@ -141,7 +141,7 @@
|
||||
expect(data_get($payload, 'readiness.resolution_case.primary_action.key'))->toBe('open_successor_review')
|
||||
->and(data_get($payload, 'readiness.resolution_case.primary_action.label'))->toBe('Open draft review')
|
||||
->and(data_get($payload, 'readiness.resolution_case.title'))->toBe('Draft review exists')
|
||||
->and(data_get($payload, 'readiness.resolution_case.reason'))->toContain('Open the draft review to refresh inputs');
|
||||
->and(data_get($payload, 'readiness.resolution_case.reason'))->toContain('Open the draft review to publish the next governed outcome');
|
||||
});
|
||||
|
||||
it('recognizes the newly created successor when the operator returns to the workspace', function (): void {
|
||||
|
||||
@ -97,7 +97,8 @@ function spec308SeedPackDecisionFinding(ManagedEnvironment $tenant, User $reques
|
||||
->and(data_get($metadata, 'delivery_bundle.appendix.2.file'))->toBe('sections.json')
|
||||
->and(data_get($metadata, 'options.include_operations'))->toBeFalse()
|
||||
->and(data_get($summary, 'delivery_bundle.executive_entrypoint_file'))->toBe(ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME)
|
||||
->and(data_get($summary, 'environment_review_id'))->toBe((int) $review->getKey())
|
||||
->and(data_get($metadata, 'environment_review.id'))->toBeNull()
|
||||
->and(data_get($summary, 'environment_review_id'))->toBeNull()
|
||||
->and(collect($sections)->pluck('section_key')->all())->not->toContain('operations_health')
|
||||
->and($executiveEntrypoint)->toContain('ManagedEnvironment: [REDACTED]')
|
||||
->and($executiveEntrypoint)->toContain('This executive entrypoint is the first file to read')
|
||||
|
||||
@ -2,17 +2,17 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Services\Settings\SettingsWriter;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@ -62,7 +62,7 @@ function reviewPackDownloadTestZipContents(array $files = []): string
|
||||
}
|
||||
|
||||
try {
|
||||
$zip = new \ZipArchive();
|
||||
$zip = new \ZipArchive;
|
||||
$result = $zip->open($tempFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
|
||||
|
||||
if ($result !== true) {
|
||||
@ -98,8 +98,7 @@ function createCurrentReviewPackForRenderedReport(
|
||||
?array $packOverrides = [],
|
||||
bool $customerSafeReady = false,
|
||||
?\App\Models\EvidenceSnapshot $snapshot = null,
|
||||
): array
|
||||
{
|
||||
): array {
|
||||
$packOverrides ??= [];
|
||||
$tenant = \App\Models\ManagedEnvironment::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
@ -192,11 +191,11 @@ function suspendReadyPackWorkspaceForDownloadTest(ReviewPack $pack): void
|
||||
// ─── Happy Path: Signed URL → 200 ───────────────────────────
|
||||
|
||||
it('downloads a ready pack via signed URL with correct headers', function (): void {
|
||||
[$user, $tenant, $pack] = createReadyPackWithFile();
|
||||
[$user, $tenant, $review, $pack] = createCurrentReviewPackForRenderedReport(customerSafeReady: true);
|
||||
|
||||
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack, [
|
||||
'source_surface' => 'customer_review_workspace',
|
||||
'review_id' => '789',
|
||||
'review_id' => (int) $review->getKey(),
|
||||
'tenant_filter_id' => (string) $tenant->getKey(),
|
||||
'interpretation_version' => 'compliance_evidence_mapping.v1',
|
||||
]);
|
||||
@ -218,15 +217,16 @@ function suspendReadyPackWorkspaceForDownloadTest(ReviewPack $pack): void
|
||||
->and($audit?->resource_type)->toBe('review_pack')
|
||||
->and(data_get($audit?->metadata, 'review_pack_id'))->toBe((int) $pack->getKey())
|
||||
->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace')
|
||||
->and(data_get($audit?->metadata, 'review_id'))->toBe('789')
|
||||
->and(data_get($audit?->metadata, 'review_id'))->toBe((string) $review->getKey())
|
||||
->and(data_get($audit?->metadata, 'tenant_filter_id'))->toBe((string) $tenant->getKey())
|
||||
->and(data_get($audit?->metadata, 'interpretation_version'))->toBe('compliance_evidence_mapping.v1')
|
||||
->and(data_get($audit?->metadata, 'download_mode'))->toBe('customer_output')
|
||||
->and(ReviewPack::query()->count())->toBe($packCount)
|
||||
->and(OperationRun::query()->count())->toBe($operationRunCount);
|
||||
});
|
||||
|
||||
it('keeps ready pack downloads available while the workspace is suspended read-only', function (): void {
|
||||
[$user, $tenant, $pack] = createReadyPackWithFile();
|
||||
[$user, $tenant, , $pack] = createCurrentReviewPackForRenderedReport(customerSafeReady: true);
|
||||
suspendReadyPackWorkspaceForDownloadTest($pack);
|
||||
|
||||
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack, [
|
||||
@ -248,14 +248,16 @@ function suspendReadyPackWorkspaceForDownloadTest(ReviewPack $pack): void
|
||||
'review_id' => (int) $review->getKey(),
|
||||
'interpretation_version' => $review->controlInterpretationVersion(),
|
||||
]);
|
||||
$this->app->instance(GraphClientInterface::class, new FailHardGraphClient());
|
||||
$this->app->instance(GraphClientInterface::class, new FailHardGraphClient);
|
||||
$packCount = ReviewPack::query()->count();
|
||||
$operationRunCount = OperationRun::query()->count();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
expect(\App\Filament\Resources\EnvironmentReviewResource::renderedReportActionLabelFor($review))->toBe('View customer-safe report')
|
||||
->and(\App\Filament\Resources\ReviewPackResource::downloadActionLabelFor($pack))->toBe('Download customer-safe review pack');
|
||||
|
||||
$response = $this->actingAs($user)->get($signedUrl);
|
||||
$response = $this->get($signedUrl);
|
||||
$content = (string) $response->getContent();
|
||||
$toolbarPosition = strpos($content, 'data-testid="rendered-report-toolbar"');
|
||||
$canvasPosition = strpos($content, 'data-testid="rendered-report-canvas"');
|
||||
@ -306,7 +308,7 @@ function suspendReadyPackWorkspaceForDownloadTest(ReviewPack $pack): void
|
||||
$pack = $pack->fresh(['environmentReview']);
|
||||
|
||||
expect(\App\Filament\Resources\EnvironmentReviewResource::renderedReportActionLabelFor($review))->toBe('View report with limitations')
|
||||
->and(\App\Filament\Resources\ReviewPackResource::downloadActionLabelFor($pack))->toBe('Download review pack with limitations');
|
||||
->and(\App\Filament\Resources\ReviewPackResource::downloadActionLabelFor($pack))->toBe('Download internal preview');
|
||||
|
||||
$signedUrl = app(ReviewPackService::class)->generateRenderedReportUrl($pack, [
|
||||
'source_surface' => 'review_pack',
|
||||
@ -321,7 +323,7 @@ function suspendReadyPackWorkspaceForDownloadTest(ReviewPack $pack): void
|
||||
->assertSee('Report with limitations')
|
||||
->assertSee('Do not share externally before review.')
|
||||
->assertSee('Output limitations')
|
||||
->assertSee('Download review pack with limitations')
|
||||
->assertSee('Download internal preview')
|
||||
->assertSee('The evidence basis is incomplete, stale, or missing.')
|
||||
->assertSee('Review or refresh the evidence basis before external sharing.')
|
||||
->assertSee('This report is anchored to Evidence snapshot #')
|
||||
@ -348,7 +350,7 @@ function suspendReadyPackWorkspaceForDownloadTest(ReviewPack $pack): void
|
||||
);
|
||||
|
||||
expect(\App\Filament\Resources\EnvironmentReviewResource::renderedReportActionLabelFor($review))->toBe('View internal report')
|
||||
->and(\App\Filament\Resources\ReviewPackResource::downloadActionLabelFor($pack))->toBe('Download internal review pack');
|
||||
->and(\App\Filament\Resources\ReviewPackResource::downloadActionLabelFor($pack))->toBe('Download internal preview');
|
||||
|
||||
$signedUrl = app(ReviewPackService::class)->generateRenderedReportUrl($pack, [
|
||||
'source_surface' => 'review_pack',
|
||||
@ -362,7 +364,7 @@ function suspendReadyPackWorkspaceForDownloadTest(ReviewPack $pack): void
|
||||
->assertSee('Internal report with limitations')
|
||||
->assertSee('Do not share externally before review.')
|
||||
->assertSee('This output includes internal or PII-bearing detail.')
|
||||
->assertSee('Download internal review pack')
|
||||
->assertSee('Download internal preview')
|
||||
->assertDontSee('Customer-safe report ready')
|
||||
->assertDontSee('Customer-ready report')
|
||||
->assertDontSee('Certified report')
|
||||
|
||||
@ -4,8 +4,8 @@
|
||||
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use App\Support\ReviewPackStatus;
|
||||
@ -14,8 +14,8 @@
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Livewire\Livewire;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@ -34,6 +34,39 @@ function getReviewPackRbacEmptyStateAction(Testable $component, string $name): ?
|
||||
return null;
|
||||
}
|
||||
|
||||
function createCustomerSafeReviewPackForRbac(ManagedEnvironment $tenant, \App\Models\User $user, string $filePath): ReviewPack
|
||||
{
|
||||
$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);
|
||||
|
||||
$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,
|
||||
],
|
||||
'file_path' => $filePath,
|
||||
'file_disk' => 'exports',
|
||||
'expires_at' => now()->addDay(),
|
||||
]);
|
||||
|
||||
$review->forceFill([
|
||||
'current_export_review_pack_id' => (int) $pack->getKey(),
|
||||
])->save();
|
||||
|
||||
return $pack->fresh(['tenant', 'environmentReview']);
|
||||
}
|
||||
|
||||
// ─── Non-Member Access ───────────────────────────────────────
|
||||
|
||||
it('returns 404 for non-member on list page', function (): void {
|
||||
@ -115,13 +148,7 @@ function getReviewPackRbacEmptyStateAction(Testable $component, string $name): ?
|
||||
$filePath = 'review-packs/readonly-test.zip';
|
||||
Storage::disk('exports')->put($filePath, 'PK-fake');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'file_path' => $filePath,
|
||||
'file_disk' => 'exports',
|
||||
]);
|
||||
$pack = createCustomerSafeReviewPackForRbac($tenant, $user, $filePath);
|
||||
|
||||
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack);
|
||||
|
||||
|
||||
@ -144,6 +144,7 @@ function createCurrentReviewPackForResourcePreview(ManagedEnvironment $tenant, \
|
||||
'published_at' => now(),
|
||||
'published_by_user_id' => (int) $user->getKey(),
|
||||
])->save();
|
||||
$review = markEnvironmentReviewCustomerSafeReady($review);
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
@ -151,6 +152,10 @@ function createCurrentReviewPackForResourcePreview(ManagedEnvironment $tenant, \
|
||||
'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,
|
||||
],
|
||||
'expires_at' => now()->addDay(),
|
||||
]);
|
||||
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
use App\Jobs\GenerateReviewPackJob;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\User;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@ -39,7 +38,7 @@
|
||||
|
||||
expect($pack->status)->toBe(ReviewPackStatus::Ready->value)
|
||||
->and(data_get($summary, 'has_ready_export'))->toBeTrue()
|
||||
->and(data_get($summary, 'output_readiness.readiness_state'))->toBe('published_with_limitations')
|
||||
->and(data_get($summary, 'output_readiness.readiness_label'))->toBe('Published with limitations')
|
||||
->and(data_get($summary, 'output_readiness.evidence_completeness_state'))->toBe((string) $snapshot->completeness_state)
|
||||
->and((int) data_get($summary, 'output_readiness.section_summary.required_limited'))->toBeGreaterThan(0)
|
||||
->and($limitedSection)->not->toBeNull();
|
||||
|
||||
@ -94,7 +94,7 @@
|
||||
expect($guidance['state'])->toBe('internal_only')
|
||||
->and($guidance['label'])->toBe('Internal review package available')
|
||||
->and($guidance['primary_action']['label'])->toBe('Review PII/redaction state')
|
||||
->and(collect($guidance['secondary_actions'])->pluck('label')->all())->toContain('Download internal review pack');
|
||||
->and(collect($guidance['secondary_actions'])->pluck('label')->all())->toContain('Download internal preview');
|
||||
});
|
||||
|
||||
it('marks complete non-pii exports as customer-safe ready', function (): void {
|
||||
|
||||
@ -83,7 +83,7 @@
|
||||
'framework placeholder' => [ReportProfileRegistry::FRAMEWORK_READINESS],
|
||||
]);
|
||||
|
||||
it('keeps customer-facing profiles visibly limited when pii-bearing output is requested', function (): void {
|
||||
it('blocks customer-facing profiles when pii-bearing output is requested', function (): void {
|
||||
[$user, $tenant, $review, $pack] = spec357CreateCurrentReviewPackForRenderedReport(
|
||||
packOverrides: [
|
||||
'options' => [
|
||||
@ -103,12 +103,7 @@
|
||||
|
||||
$this->actingAs($user)
|
||||
->get($signedUrl)
|
||||
->assertOk()
|
||||
->assertSee(__('localization.review.report_profile_customer_executive'))
|
||||
->assertSee(__('localization.review.report_external_sharing_warning'))
|
||||
->assertSee(__('localization.review.report_disclosure_customer_profile_internal_only'))
|
||||
->assertSee(__('localization.review.proof_state_missing'))
|
||||
->assertDontSee(__('localization.review.report_state_customer_safe_ready'));
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@ -109,7 +109,7 @@
|
||||
->and(OperationRun::query()->count())->toBe($operationRunCount);
|
||||
});
|
||||
|
||||
it('keeps limited and PII-bearing report states visibly bounded', function (): void {
|
||||
it('blocks customer-facing limited and PII-bearing report states while allowing operator internal profile review', function (): void {
|
||||
[$limitedUser, $limitedTenant, $limitedReview, $limitedPack] = spec366CreateRenderedReportPack(customerSafeReady: true);
|
||||
restateEnvironmentReviewEvidenceSnapshot($limitedReview->evidenceSnapshot, EvidenceCompletenessState::Partial);
|
||||
|
||||
@ -121,18 +121,7 @@
|
||||
),
|
||||
);
|
||||
|
||||
$limitedResponse->assertOk()
|
||||
->assertSee('Report with limitations')
|
||||
->assertSee('Do not share externally before review.')
|
||||
->assertSee('Output limitations')
|
||||
->assertSee('Review or refresh the evidence basis before external sharing.')
|
||||
->assertSee('data-layout-mode="executive"', false)
|
||||
->assertSee('data-section-rank="30"', false)
|
||||
->assertDontSee('Customer-ready report')
|
||||
->assertDontSee('Certified report')
|
||||
->assertDontSee('Approved compliance report')
|
||||
->assertDontSee('Share with customer')
|
||||
->assertDontSee('localization.');
|
||||
$limitedResponse->assertForbidden();
|
||||
|
||||
expect($limitedTenant)->toBeInstanceOf(ManagedEnvironment::class);
|
||||
|
||||
@ -149,12 +138,14 @@
|
||||
|
||||
$this->actingAs($piiUser)
|
||||
->get(spec366RenderedReportUrl($piiPack, $piiReview, ReportProfileRegistry::CUSTOMER_EXECUTIVE))
|
||||
->assertForbidden();
|
||||
|
||||
$this->actingAs($piiUser)
|
||||
->get(spec366RenderedReportUrl($piiPack, $piiReview, ReportProfileRegistry::INTERNAL_MSP_REVIEW))
|
||||
->assertOk()
|
||||
->assertSee('Internal report with limitations')
|
||||
->assertSee('Do not share externally before review.')
|
||||
->assertSee('Customer-facing profile blocked by internal-only detail')
|
||||
->assertSee('Protected values boundary')
|
||||
->assertSee('data-layout-mode="executive"', false)
|
||||
->assertDontSee('Customer-safe report ready')
|
||||
->assertDontSee('Certified report')
|
||||
->assertDontSee('Approved compliance report')
|
||||
|
||||
@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\StoredReport;
|
||||
use App\Services\ReviewPacks\ManagementReportPdfService;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\ReviewPacks\CustomerOutputGate;
|
||||
use App\Support\ReviewPacks\ReportProfileRegistry;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
function spec392RouteCurrentReviewPack(bool $customerSafeReady = true): 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);
|
||||
|
||||
if (! $customerSafeReady) {
|
||||
restateEnvironmentReviewEvidenceSnapshot($review->evidenceSnapshot, EvidenceCompletenessState::Partial);
|
||||
$review = $review->fresh(['sections', 'evidenceSnapshot']);
|
||||
}
|
||||
|
||||
$zipBytes = 'spec392 review pack bytes';
|
||||
$filePath = sprintf('review-packs/%s/spec392-current.zip', $tenant->external_id);
|
||||
Storage::disk('exports')->put($filePath, $zipBytes);
|
||||
|
||||
$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) $review->evidence_snapshot_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'options' => [
|
||||
'include_pii' => false,
|
||||
'include_operations' => true,
|
||||
],
|
||||
'summary' => [
|
||||
'governance_package' => [
|
||||
'executive_summary' => 'Spec392 management-ready review pack.',
|
||||
'top_findings' => [],
|
||||
'accepted_risks' => [],
|
||||
'decision_summary' => [
|
||||
'entries' => [],
|
||||
],
|
||||
],
|
||||
'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.',
|
||||
],
|
||||
'delivery_bundle' => [
|
||||
'executive_entrypoint_file' => 'executive-summary.md',
|
||||
'appendix_files' => ['metadata.json'],
|
||||
],
|
||||
],
|
||||
'file_disk' => 'exports',
|
||||
'file_path' => $filePath,
|
||||
'file_size' => strlen($zipBytes),
|
||||
'sha256' => hash('sha256', $zipBytes),
|
||||
'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 spec392ReadyManagementPdf(ReviewPack $pack): StoredReport
|
||||
{
|
||||
$pdfBytes = '%PDF-1.7 Spec392 management report';
|
||||
$filePath = sprintf('management-reports/%s/spec392-%d.pdf', $pack->tenant->external_id, (int) $pack->getKey());
|
||||
Storage::disk('exports')->put($filePath, $pdfBytes);
|
||||
|
||||
return StoredReport::factory()->managementReportPdf([
|
||||
'title' => 'Spec392 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('Spec392 blocks unsafe customer-output downloads but allows authorized internal preview', function (): void {
|
||||
[$owner, $tenant, $review, $pack] = spec392RouteCurrentReviewPack(customerSafeReady: false);
|
||||
[$readonly] = createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
user: \App\Models\User::factory()->create(),
|
||||
role: 'readonly',
|
||||
clearCapabilityCaches: true,
|
||||
);
|
||||
|
||||
$customerUrl = app(ReviewPackService::class)->generateDownloadUrl($pack, [
|
||||
'source_surface' => 'customer_review_workspace',
|
||||
'review_id' => (int) $review->getKey(),
|
||||
]);
|
||||
$internalPreviewUrl = app(ReviewPackService::class)->generateDownloadUrl($pack, [
|
||||
'source_surface' => 'review_pack',
|
||||
CustomerOutputGate::INTERNAL_PREVIEW_QUERY_KEY => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($owner)->get($customerUrl)->assertForbidden();
|
||||
$this->actingAs($readonly)->get($internalPreviewUrl)->assertForbidden();
|
||||
|
||||
$this->actingAs($owner)
|
||||
->get($internalPreviewUrl)
|
||||
->assertOk()
|
||||
->assertHeader('X-Review-Pack-SHA256', $pack->sha256)
|
||||
->assertDownload();
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('action', AuditActionId::ReviewPackDownloaded->value)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($audit)->not->toBeNull()
|
||||
->and(data_get($audit?->metadata, 'download_mode'))->toBe('internal_preview')
|
||||
->and(data_get($audit?->metadata, 'customer_output_gate_state'))->toBe(CustomerOutputGate::STATE_NEEDS_ATTENTION);
|
||||
});
|
||||
|
||||
it('Spec392 renders unsafe reports only on internal profile routes without customer download copy', function (): void {
|
||||
[$owner, $tenant, $review, $pack] = spec392RouteCurrentReviewPack(customerSafeReady: false);
|
||||
[$readonly] = createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
user: \App\Models\User::factory()->create(),
|
||||
role: 'readonly',
|
||||
clearCapabilityCaches: true,
|
||||
);
|
||||
|
||||
$customerReportUrl = app(ReviewPackService::class)->generateRenderedReportUrl($pack, [
|
||||
'source_surface' => 'customer_review_workspace',
|
||||
\App\Filament\Pages\Reviews\CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1,
|
||||
'review_id' => (int) $review->getKey(),
|
||||
ReportProfileRegistry::QUERY_PARAMETER => ReportProfileRegistry::CUSTOMER_EXECUTIVE,
|
||||
]);
|
||||
$internalReportUrl = app(ReviewPackService::class)->generateRenderedReportUrl($pack, [
|
||||
'source_surface' => 'review_pack',
|
||||
'review_id' => (int) $review->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($owner)->get($customerReportUrl)->assertForbidden();
|
||||
$this->actingAs($readonly)->get($internalReportUrl)->assertForbidden();
|
||||
|
||||
$this->actingAs($owner)
|
||||
->get($internalReportUrl)
|
||||
->assertOk()
|
||||
->assertSee('Report with limitations')
|
||||
->assertSee('Download internal preview')
|
||||
->assertDontSee('Customer-safe report ready');
|
||||
});
|
||||
|
||||
it('Spec392 blocks management PDF downloads when the source review pack is not customer safe', function (): void {
|
||||
[$owner, , , $pack] = spec392RouteCurrentReviewPack(customerSafeReady: false);
|
||||
$report = spec392ReadyManagementPdf($pack);
|
||||
$url = app(ManagementReportPdfService::class)->generateDownloadUrl($report, [
|
||||
'source_surface' => 'review_pack',
|
||||
]);
|
||||
|
||||
$this->actingAs($owner)
|
||||
->get($url)
|
||||
->assertForbidden();
|
||||
|
||||
expect(AuditLog::query()
|
||||
->where('action', AuditActionId::ManagementReportPdfDownloaded->value)
|
||||
->count())->toBe(0);
|
||||
});
|
||||
@ -212,7 +212,7 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(ManagedEnvironment $t
|
||||
->assertSee(EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $review->fresh()], $tenant), false)
|
||||
->assertSee('Output not customer-ready')
|
||||
->assertSee('Review blockers are still recorded for this output.')
|
||||
->assertSee('Download review pack with limitations')
|
||||
->assertDontSee('Download internal preview')
|
||||
->assertSee('Available');
|
||||
});
|
||||
|
||||
|
||||
@ -218,7 +218,7 @@
|
||||
->assertSee('Support details stay on authorized diagnostic surfaces')
|
||||
->assertSee('Customer acceptance checkpoint')
|
||||
->assertSee('Open review')
|
||||
->assertSee('Download review pack with limitations')
|
||||
->assertDontSee('Download internal preview')
|
||||
->assertDontSee('raw payload should stay hidden')
|
||||
->assertDontSee('stack trace should stay hidden')
|
||||
->assertDontSee('internal exception should stay hidden')
|
||||
@ -300,7 +300,7 @@
|
||||
->assertSee('Accepted-risk follow-up is required.')
|
||||
->assertSee('Open review')
|
||||
->assertSeeInOrder(['Published with limitations', 'Open review'])
|
||||
->assertSee('Download review pack with limitations')
|
||||
->assertDontSee('Download internal preview')
|
||||
->assertDontSee('Ready to share');
|
||||
});
|
||||
|
||||
@ -367,9 +367,9 @@
|
||||
->assertSee('Output not customer-ready')
|
||||
->assertSee('Review blockers are still recorded for this output.')
|
||||
->assertDontSee('No operation proof linked')
|
||||
->assertSee('Export ready')
|
||||
->assertSee('Available')
|
||||
->assertDontSee('Ready to share')
|
||||
->assertSee('Download review pack with limitations');
|
||||
->assertDontSee('Download internal preview');
|
||||
});
|
||||
|
||||
it('shows the current released review using deterministic published review ordering', function (): void {
|
||||
|
||||
@ -14,12 +14,12 @@
|
||||
'icon' => 'heroicon-o-arrow-top-right-on-square',
|
||||
], 'review_output.open_review');
|
||||
$download = ResolutionAction::fromArray([
|
||||
'key' => 'review_output.download_review_pack_with_limitations',
|
||||
'label' => 'Download review pack with limitations',
|
||||
'key' => 'review_output.download_internal_preview',
|
||||
'label' => 'Download internal preview',
|
||||
'url' => '/admin/review-packs/1/download',
|
||||
'kind' => 'download',
|
||||
'icon' => 'heroicon-o-arrow-down-tray',
|
||||
], 'review_output.download_review_pack_with_limitations');
|
||||
], 'review_output.download_internal_preview');
|
||||
|
||||
expect($navigation['type'])->toBe(ResolutionAction::TYPE_NAVIGATION)
|
||||
->and($navigation['kind'])->toBe('environment_link')
|
||||
|
||||
@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ReviewPack;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\ReviewPacks\CustomerOutputGate;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function spec392GateCurrentReviewPack(bool $customerSafeReady = true): 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);
|
||||
|
||||
if (! $customerSafeReady) {
|
||||
restateEnvironmentReviewEvidenceSnapshot($review->evidenceSnapshot, EvidenceCompletenessState::Partial);
|
||||
$review = $review->fresh(['sections', 'evidenceSnapshot']);
|
||||
}
|
||||
|
||||
$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) $review->evidence_snapshot_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'options' => [
|
||||
'include_pii' => false,
|
||||
'include_operations' => true,
|
||||
],
|
||||
'file_disk' => 'exports',
|
||||
'file_path' => 'review-packs/spec392-gate.zip',
|
||||
'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'])];
|
||||
}
|
||||
|
||||
it('allows customer output only when the current review pack is customer safe and the actor can view review packs', function (): void {
|
||||
[, $tenant, , $pack] = spec392GateCurrentReviewPack(customerSafeReady: true);
|
||||
[$viewer] = createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
user: \App\Models\User::factory()->create(),
|
||||
role: 'readonly',
|
||||
clearCapabilityCaches: true,
|
||||
);
|
||||
|
||||
$decision = app(CustomerOutputGate::class)->decisionForReviewPack($pack, $viewer);
|
||||
|
||||
expect($decision->canStreamCustomerOutput)->toBeTrue()
|
||||
->and($decision->canStreamInternalPreview)->toBeFalse()
|
||||
->and($decision->state)->toBe(CustomerOutputGate::STATE_READY);
|
||||
});
|
||||
|
||||
it('denies customer output when no actor is provided even if the output is ready', function (): void {
|
||||
[, , , $pack] = spec392GateCurrentReviewPack(customerSafeReady: true);
|
||||
|
||||
$decision = app(CustomerOutputGate::class)->decisionForReviewPack($pack, null);
|
||||
|
||||
expect($decision->canStreamCustomerOutput)->toBeFalse()
|
||||
->and($decision->canStreamInternalPreview)->toBeFalse()
|
||||
->and($decision->state)->toBe(CustomerOutputGate::STATE_READY);
|
||||
});
|
||||
|
||||
it('denies customer output when the actor is missing review pack view capability even if the output is ready', function (): void {
|
||||
[, , , $pack] = spec392GateCurrentReviewPack(customerSafeReady: true);
|
||||
$unauthorizedActor = \App\Models\User::factory()->create();
|
||||
|
||||
$decision = app(CustomerOutputGate::class)->decisionForReviewPack($pack, $unauthorizedActor);
|
||||
|
||||
expect($decision->canStreamCustomerOutput)->toBeFalse()
|
||||
->and($decision->canStreamInternalPreview)->toBeFalse()
|
||||
->and($decision->state)->toBe(CustomerOutputGate::STATE_READY);
|
||||
});
|
||||
|
||||
it('denies customer output when the actor can view review packs but the output is not ready', function (): void {
|
||||
[, $tenant, , $pack] = spec392GateCurrentReviewPack(customerSafeReady: false);
|
||||
[$viewer] = createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
user: \App\Models\User::factory()->create(),
|
||||
role: 'readonly',
|
||||
clearCapabilityCaches: true,
|
||||
);
|
||||
|
||||
$decision = app(CustomerOutputGate::class)->decisionForReviewPack($pack, $viewer);
|
||||
|
||||
expect($decision->canStreamCustomerOutput)->toBeFalse()
|
||||
->and($decision->canStreamInternalPreview)->toBeFalse()
|
||||
->and($decision->state)->toBe(CustomerOutputGate::STATE_NEEDS_ATTENTION);
|
||||
});
|
||||
|
||||
it('keeps unsafe review packs behind internal preview authorization', function (): void {
|
||||
[$owner, $tenant, , $pack] = spec392GateCurrentReviewPack(customerSafeReady: false);
|
||||
[$readonly] = createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
user: \App\Models\User::factory()->create(),
|
||||
role: 'readonly',
|
||||
clearCapabilityCaches: true,
|
||||
);
|
||||
|
||||
$ownerDecision = app(CustomerOutputGate::class)->decisionForReviewPack($pack, $owner);
|
||||
$readonlyDecision = app(CustomerOutputGate::class)->decisionForReviewPack($pack, $readonly);
|
||||
|
||||
expect($ownerDecision->canStreamCustomerOutput)->toBeFalse()
|
||||
->and($ownerDecision->canStreamInternalPreview)->toBeTrue()
|
||||
->and($readonlyDecision->canStreamCustomerOutput)->toBeFalse()
|
||||
->and($readonlyDecision->canStreamInternalPreview)->toBeFalse();
|
||||
});
|
||||
|
||||
it('classifies pii-bearing output as internal preview only', function (): void {
|
||||
[$owner, , , $pack] = spec392GateCurrentReviewPack(customerSafeReady: true);
|
||||
$pack->forceFill([
|
||||
'options' => [
|
||||
'include_pii' => true,
|
||||
'include_operations' => true,
|
||||
],
|
||||
])->save();
|
||||
|
||||
$decision = app(CustomerOutputGate::class)->decisionForReviewPack($pack->fresh(['tenant', 'environmentReview']), $owner);
|
||||
|
||||
expect($decision->canStreamCustomerOutput)->toBeFalse()
|
||||
->and($decision->canStreamInternalPreview)->toBeTrue()
|
||||
->and($decision->state)->toBe(CustomerOutputGate::STATE_INTERNAL_ONLY);
|
||||
});
|
||||
|
||||
it('blocks missing and expired artifacts before customer output streaming', function (): void {
|
||||
[$owner, , , $pack] = spec392GateCurrentReviewPack(customerSafeReady: true);
|
||||
|
||||
$missingArtifact = $pack->replicate()->forceFill([
|
||||
'fingerprint' => fake()->sha256(),
|
||||
'file_path' => null,
|
||||
'file_disk' => null,
|
||||
]);
|
||||
$missingArtifact->save();
|
||||
|
||||
$expiredArtifact = $pack->replicate()->forceFill([
|
||||
'fingerprint' => fake()->sha256(),
|
||||
'expires_at' => now()->subMinute(),
|
||||
]);
|
||||
$expiredArtifact->save();
|
||||
|
||||
$missingDecision = app(CustomerOutputGate::class)->decisionForReviewPack($missingArtifact->fresh(['tenant', 'environmentReview']), $owner);
|
||||
$expiredDecision = app(CustomerOutputGate::class)->decisionForReviewPack($expiredArtifact->fresh(['tenant', 'environmentReview']), $owner);
|
||||
|
||||
expect($missingDecision->canStreamCustomerOutput)->toBeFalse()
|
||||
->and($missingDecision->canStreamInternalPreview)->toBeFalse()
|
||||
->and($missingDecision->state)->toBe(CustomerOutputGate::STATE_NOT_AVAILABLE)
|
||||
->and($expiredDecision->canStreamCustomerOutput)->toBeFalse()
|
||||
->and($expiredDecision->canStreamInternalPreview)->toBeFalse()
|
||||
->and($expiredDecision->state)->toBe(CustomerOutputGate::STATE_EXPIRED);
|
||||
});
|
||||
|
||||
it('does not treat unanchored review pack artifacts as customer output', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', clearCapabilityCaches: true);
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'file_disk' => 'exports',
|
||||
'file_path' => 'review-packs/spec392-unanchored.zip',
|
||||
'expires_at' => now()->addDay(),
|
||||
]);
|
||||
|
||||
$decision = app(CustomerOutputGate::class)->decisionForReviewPack($pack, $user);
|
||||
|
||||
expect($decision->canStreamCustomerOutput)->toBeFalse()
|
||||
->and($decision->canStreamInternalPreview)->toBeTrue()
|
||||
->and($decision->state)->toBe(CustomerOutputGate::STATE_UNKNOWN);
|
||||
});
|
||||
@ -0,0 +1,61 @@
|
||||
# Requirements Checklist: Spec 392 - Customer Output Gating & Review Pack Navigation v1
|
||||
|
||||
**Purpose**: Validate candidate selection, Spec Kit artifact readiness, constitution alignment, and implementation-loop readiness for Spec 392.
|
||||
**Created**: 2026-06-20
|
||||
**Feature**: `specs/392-customer-output-gating-review-pack-navigation/spec.md`
|
||||
|
||||
## Candidate Selection Gate
|
||||
|
||||
- [x] CHK001 Selected candidate exists in source material: user-provided Spec 392 draft and browser productization BUG-007.
|
||||
- [x] CHK002 Selected candidate is not already covered by an existing active or completed Spec 392 package.
|
||||
- [x] CHK003 Related completed specs 342, 347, 351, and 372 are treated as read-only historical context.
|
||||
- [x] CHK004 Broad Customer Review Workspace v1 completion is explicitly excluded as completed/historical.
|
||||
- [x] CHK005 Scope is small enough for a bounded implementation loop: customer-output gate enforcement and truthful navigation only.
|
||||
- [x] CHK006 Close alternatives are deferred instead of hidden inside Spec 392.
|
||||
|
||||
## Spec Completeness
|
||||
|
||||
- [x] CHK007 `spec.md` defines problem, user value, user stories, functional requirements, non-goals, acceptance criteria, success criteria, assumptions, risks, and follow-up candidates.
|
||||
- [x] CHK008 `plan.md` lists likely affected repo surfaces and distinguishes implementation touch points from supporting surfaces to inspect.
|
||||
- [x] CHK009 `tasks.md` is ordered into small phases with tests, route enforcement, UI correction, browser smoke, validation, and explicit non-goals.
|
||||
- [x] CHK010 No unresolved open question blocks safe implementation.
|
||||
- [x] CHK011 No template placeholders remain in `spec.md`, `plan.md`, or `tasks.md`.
|
||||
|
||||
## Constitution And Architecture Alignment
|
||||
|
||||
- [x] CHK012 Spec Candidate Check is filled, scored 11/12, and approved as Core Enterprise.
|
||||
- [x] CHK013 Proportionality Review is present for the possible narrow gate/result adapter.
|
||||
- [x] CHK014 The spec does not introduce new migrations, persisted readiness truth, enum/status persistence, provider framework, or customer portal architecture.
|
||||
- [x] CHK015 Workspace/environment isolation, deny-as-not-found semantics, capability checks, and route-level enforcement are explicit.
|
||||
- [x] CHK016 UI Surface Impact and UI/Productization Coverage are completed for existing customer-facing/action surfaces.
|
||||
- [x] CHK017 Cross-cutting shared pattern reuse is explicit and prefers existing readiness/disclosure helpers.
|
||||
- [x] CHK018 OperationRun impact is bounded to existing proof links only.
|
||||
- [x] CHK019 Provider boundary check confirms no new provider/platform seam.
|
||||
- [x] CHK020 Test governance names Unit, Feature/HTTP, Filament/Livewire, and Browser lanes with bounded fixture cost.
|
||||
|
||||
## Filament / Livewire / Deployment Contract
|
||||
|
||||
- [x] CHK021 Filament v5 explicitly targets Livewire v4.0+; current app info reports Livewire 4.1.4.
|
||||
- [x] CHK022 Provider registration location is recorded as `apps/platform/bootstrap/providers.php`; no panel provider change is expected.
|
||||
- [x] CHK023 Global search posture is preserved; no new global-search behavior is planned.
|
||||
- [x] CHK024 No destructive action is introduced; existing destructive/high-impact actions remain out of scope and must retain confirmation, authorization, audit, and tests.
|
||||
- [x] CHK025 Asset strategy is unchanged; no Filament asset registration or `filament:assets` deployment change is expected.
|
||||
- [x] CHK026 Deployment impact explicitly says no env vars, migrations, queues, scheduler, storage topology, Graph scopes, or Dokploy changes are expected.
|
||||
|
||||
## Task Readiness
|
||||
|
||||
- [x] CHK027 Tasks include repo-truth inventory before implementation.
|
||||
- [x] CHK028 Tasks include tests before or alongside gate and route enforcement.
|
||||
- [x] CHK029 Tasks include direct-route bypass protection.
|
||||
- [x] CHK030 Tasks include customer-workspace CTA destination truth.
|
||||
- [x] CHK031 Tasks include internal-preview separation.
|
||||
- [x] CHK032 Tasks include browser smoke and direct URL proof.
|
||||
- [x] CHK033 Tasks include final validation commands.
|
||||
- [x] CHK034 Tasks include stop conditions for broader scope.
|
||||
|
||||
## Review Outcome
|
||||
|
||||
- [x] CHK035 Review outcome class: acceptable-special-case.
|
||||
- [x] CHK036 Workflow outcome: keep.
|
||||
- [x] CHK037 Final note location: active feature PR close-out entry `Guardrail / Exception / Smoke Coverage`.
|
||||
- [x] CHK038 Result: ready for implementation loop after manual artifact review.
|
||||
351
specs/392-customer-output-gating-review-pack-navigation/plan.md
Normal file
351
specs/392-customer-output-gating-review-pack-navigation/plan.md
Normal file
@ -0,0 +1,351 @@
|
||||
# Implementation Plan: Spec 392 - Customer Output Gating & Review Pack Navigation v1
|
||||
|
||||
**Branch**: `392-customer-output-gating-review-pack-navigation` | **Date**: 2026-06-20 | **Spec**: `specs/392-customer-output-gating-review-pack-navigation/spec.md`
|
||||
**Input**: Feature specification from `specs/392-customer-output-gating-review-pack-navigation/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Implement a route-enforced Customer Output Gate for existing customer-facing review output and correct misleading Review Pack / Customer Workspace navigation labels. The implementation should reuse existing review-pack readiness and disclosure truth first, then introduce one narrow gate/result adapter only if needed to make UI and direct routes consume the same decision.
|
||||
|
||||
The primary safety requirement is simple: customer output may be opened or downloaded only when the same canonical decision says it is ready and customer-safe. Internal preview remains separate, secondary, and permission-protected.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12.52, Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1
|
||||
**Storage**: PostgreSQL; no new storage expected
|
||||
**Testing**: Pest 4, Laravel Feature/HTTP tests, Filament/Livewire tests, bounded Browser smoke
|
||||
**Validation Lanes**: fast-feedback, confidence, browser
|
||||
**Target Platform**: Laravel Sail local; Dokploy container deployment for staging/production
|
||||
**Project Type**: Laravel monolith under `apps/platform`
|
||||
**Performance Goals**: DB-only gate evaluation during render/download; no Graph or remote calls
|
||||
**Constraints**: no new persisted readiness truth; no broad Customer Review Workspace rebuild; no route bypass; no unsafe customer-facing download with limitations
|
||||
**Scale/Scope**: existing review, review-pack, stored-report, rendered-report, dashboard, evidence overview, and customer-workspace output surfaces only
|
||||
|
||||
## Technical Approach
|
||||
|
||||
1. Inventory existing customer-output actions and routes.
|
||||
2. Define a single customer-output gate decision shape over existing readiness/disclosure/policy truth.
|
||||
3. Enforce the gate at direct routes before any file stream, rendered report, signed URL, or customer-facing page output.
|
||||
4. Replace misleading customer-facing labels with truthful destination-specific labels.
|
||||
5. Separate customer output from internal preview in UI, authorization, route behavior, and tests.
|
||||
6. Keep internal proof, OperationRun links, raw metadata, and diagnostics secondary or capability-gated by default.
|
||||
|
||||
The first implementation choice should be:
|
||||
|
||||
```text
|
||||
Existing readiness/disclosure truth
|
||||
-> Customer output gate/result adapter
|
||||
-> UI action state and route enforcement
|
||||
```
|
||||
|
||||
Do not create a new readiness engine. The gate may wrap current helpers such as `ReviewPackOutputReadiness`, `ReviewPackOutputResolutionGuidance`, and `ReportDisclosurePolicy` when they already express the needed truth.
|
||||
|
||||
## Likely Affected Repository Surfaces
|
||||
|
||||
Implementation must re-verify exact current code before editing, but likely surfaces are:
|
||||
|
||||
- `apps/platform/app/Support/ReviewPacks/ReviewPackOutputReadiness.php`
|
||||
- `apps/platform/app/Support/ReviewPacks/ReviewPackOutputResolutionGuidance.php`
|
||||
- `apps/platform/app/Support/ReviewPacks/ReportDisclosurePolicy.php`
|
||||
- `apps/platform/app/Http/Controllers/ReviewPackDownloadController.php`
|
||||
- `apps/platform/app/Http/Controllers/ReviewPackRenderedReportController.php`
|
||||
- `apps/platform/app/Http/Controllers/ManagementReportPdfDownloadController.php`
|
||||
- `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`
|
||||
- `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`
|
||||
- `apps/platform/resources/views/review-packs/rendered-report.blade.php`
|
||||
- `apps/platform/app/Filament/Resources/ReviewPackResource.php`
|
||||
- `apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`
|
||||
- `apps/platform/app/Filament/Resources/EnvironmentReviewResource.php`
|
||||
- `apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ViewEnvironmentReview.php`
|
||||
- `apps/platform/app/Support/EnvironmentDashboard/EnvironmentDashboardSummaryBuilder.php`
|
||||
- `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php`
|
||||
- `apps/platform/lang/en/localization.php`
|
||||
- `apps/platform/lang/de/localization.php`
|
||||
- Focused tests under `apps/platform/tests/Unit`, `apps/platform/tests/Feature`, and `apps/platform/tests/Browser`
|
||||
|
||||
Implementation may remove a surface from the touched list if repo truth shows it is not customer-facing or already correct.
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed existing customer-output actions, route access, labels, and customer/output states.
|
||||
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: Customer Review Workspace output actions, Review Pack download/report actions, management-report download if customer-facing, Review Pack/Environment Review labels, Environment Dashboard customer-workspace CTA, Evidence Overview customer-workspace links.
|
||||
- **No-impact class, if applicable**: N/A.
|
||||
- **Native vs custom classification summary**: mixed existing native Filament resources/pages plus existing Blade report/workspace composition.
|
||||
- **Shared-family relevance**: status messaging, action links, dashboard signal links, artifact/report viewers, customer-safe disclosure.
|
||||
- **State layers in scope**: page, detail, route, URL-query.
|
||||
- **Audience modes in scope**: customer/read-only, operator-MSP, support-platform where authorized.
|
||||
- **Decision/diagnostic/raw hierarchy plan**: customer-output safety decision first; diagnostics and raw/support proof second or gated.
|
||||
- **Raw/support gating plan**: collapsed or capability-gated; no raw metadata by default on customer-facing surfaces.
|
||||
- **One-primary-action / duplicate-truth control**: show one top-level customer output state and one state-appropriate primary action; demote internal preview and proof links.
|
||||
- **Handling modes by drift class or surface**: route bypass is hard-stop; misleading CTA labels are review-mandatory; internal-preview exceptions must be documented in feature.
|
||||
- **Repository-signal treatment**: BUG-007 is a hard input; broad completed specs are context only.
|
||||
- **Special surface test profiles**: global-context-shell + customer-safe strategic review surface + artifact download route.
|
||||
- **Required tests or manual smoke**: Unit gate tests, HTTP route tests, Filament/Livewire action state tests, Browser smoke.
|
||||
- **Exception path and spread control**: no exception expected; any retained limitations-bearing internal preview must be named and permission-protected.
|
||||
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage.
|
||||
- **UI/Productization coverage decision**: update existing page-report artifacts if implementation materially changes reachable page behavior; otherwise document no-count/no-archetype-change in active spec close-out.
|
||||
- **Coverage artifacts to update**: likely existing page reports under `docs/ui-ux-enterprise-audit/page-reports/...` for Customer Review Workspace / Review Pack / Environment Dashboard only if visible behavior materially changes.
|
||||
- **No-impact rationale**: N/A.
|
||||
- **Navigation / Filament provider-panel handling**: no provider registration or panel path change; verify provider registration remains `apps/platform/bootstrap/providers.php`.
|
||||
- **Screenshot or page-report need**: yes for browser proof of corrected CTA and safe/blocked output; page-report updates only if material page behavior changes.
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes.
|
||||
- **Systems touched**: review-pack readiness/disclosure, output/download routes, Filament action labels, dashboard links, evidence/report viewers, localization, policy/capability checks.
|
||||
- **Shared abstractions reused**: `ReviewPackOutputReadiness`, `ReviewPackOutputResolutionGuidance`, `ReportDisclosurePolicy`, existing policies/capabilities, existing `CustomerReviewWorkspace::environmentFilterUrl()`, existing `ReviewPackResource` URLs, existing audit logger.
|
||||
- **New abstraction introduced? why?**: none by default; one narrow gate/result adapter only if needed to ensure route and UI consume the same customer-output decision.
|
||||
- **Why the existing abstraction was sufficient or insufficient**: current readiness helpers are sufficient for most derived safety signals, but they are not currently a route-enforced access boundary across every output path.
|
||||
- **Bounded deviation / spread control**: any new gate must stay scoped to customer-output open/download decisions and may not become a generic governance readiness framework.
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: no new start/completion; existing proof links may remain secondary.
|
||||
- **Central contract reused**: existing OperationRun link helpers/routes.
|
||||
- **Delegated UX behaviors**: N/A for new starts.
|
||||
- **Surface-owned behavior kept local**: output safety copy and customer/internal action labels only.
|
||||
- **Queued DB-notification policy**: N/A.
|
||||
- **Terminal notification path**: unchanged.
|
||||
- **Exception path**: none.
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
- **Shared provider/platform boundary touched?**: no.
|
||||
- **Provider-owned seams**: N/A.
|
||||
- **Platform-core seams**: customer-output gating and artifact access labels.
|
||||
- **Neutral platform terms / contracts preserved**: customer output, customer workspace, review pack, internal preview, output state, blocking reason.
|
||||
- **Retained provider-specific semantics and why**: existing customer-safe review content may contain provider facts; gate labels should not.
|
||||
- **Bounded extraction or follow-up path**: none unless implementation finds provider-specific leakage requiring a separate follow-up.
|
||||
|
||||
## Domain And Data Implications
|
||||
|
||||
- No migration is expected.
|
||||
- No new model is expected.
|
||||
- No new persisted enum/status family is expected.
|
||||
- Gate state should be derived from existing records, summaries, readiness/disclosure payloads, file metadata, lifecycle timestamps, and authorization.
|
||||
- If existing data cannot represent a blocker required by the spec, implementation must stop and update `spec.md` / `plan.md` before adding persisted truth.
|
||||
|
||||
Expected gate result shape can be represented as array/value object fields:
|
||||
|
||||
```text
|
||||
state
|
||||
can_open_customer_workspace
|
||||
can_download_customer_output
|
||||
can_download_internal_preview
|
||||
primary_blocking_reason
|
||||
blocking_reasons[]
|
||||
is_customer_safe
|
||||
is_internal_preview_only
|
||||
customer_output_url
|
||||
internal_preview_url
|
||||
target_kind
|
||||
```
|
||||
|
||||
Allowed visible states:
|
||||
|
||||
```text
|
||||
Ready
|
||||
Needs attention
|
||||
Blocked
|
||||
Not configured
|
||||
Expired
|
||||
Unknown
|
||||
```
|
||||
|
||||
These are derived presentation states, not persisted lifecycle truth.
|
||||
|
||||
## Route And Authorization Plan
|
||||
|
||||
Route enforcement must run before output content is returned:
|
||||
|
||||
- `ReviewPackDownloadController`: verify membership, capability, output gate, artifact status, expiry, file existence, audit, stream.
|
||||
- `ReviewPackRenderedReportController`: verify membership, capability, output gate/report profile before rendering a customer-facing report or showing a customer-safe download action.
|
||||
- `ManagementReportPdfDownloadController`: if customer-facing, apply equivalent output/report gate before PDF stream.
|
||||
- Any signed/stale route: stale or blocked customer output returns 403/404/safe redirect without file content.
|
||||
|
||||
Management-report PDF customer-facing criteria:
|
||||
|
||||
- Customer-facing when labelled, linked, or positioned as customer/auditor review output from Customer Review Workspace, Review Pack, Environment Review, Environment Dashboard, Evidence Overview, or rendered-report flows.
|
||||
- Internal-only when reachable solely from operator/audit/technical surfaces and copy does not imply customer delivery.
|
||||
- Implementation must record the final include/exclude decision before editing or skipping this controller.
|
||||
|
||||
Authorization semantics:
|
||||
|
||||
- Non-member or wrong workspace/environment: 404 deny-as-not-found.
|
||||
- Entitled member without capability: 403.
|
||||
- Entitled member with capability but unsafe customer-output gate: 403 or safe admin redirect with block reason.
|
||||
- Internal preview access: capability plus internal-preview gate; never customer-output permission alone.
|
||||
|
||||
## Filament And Livewire Plan
|
||||
|
||||
- Filament v5 / Livewire v4.0+ compliance is required; this app currently uses Livewire 4.1.4.
|
||||
- Panel provider registration remains `apps/platform/bootstrap/providers.php`; no provider changes are expected.
|
||||
- No global search behavior should be added or changed.
|
||||
- No destructive actions are introduced. Existing destructive/high-impact actions on related resources remain out of scope and must keep confirmation, authorization, audit, notifications, and tests.
|
||||
- UI labels must be route-truthful:
|
||||
- `Open customer workspace` -> actual Customer Review Workspace.
|
||||
- `Open review pack` -> internal Review Pack detail.
|
||||
- `Download customer output` -> customer-safe artifact only.
|
||||
- `Download internal preview` -> internal/operator artifact only.
|
||||
- `View audit trail` / `View technical details` -> internal detail only.
|
||||
- Hidden/disabled UI state is not a security boundary; route/controller checks are mandatory.
|
||||
|
||||
## Audit And Observability Plan
|
||||
|
||||
- Successful customer-output download must keep existing `ReviewPackDownloaded` or equivalent audit events.
|
||||
- Internal preview download, if introduced or relabelled, must be auditable with safe metadata and must not log secrets/raw payloads.
|
||||
- Blocked attempts may be logged only if current audit conventions support it without noise or sensitive data. If added, use stable action IDs and safe metadata.
|
||||
- No new `OperationRun` is expected because this is access/gating, not long-running execution.
|
||||
|
||||
## Test Strategy
|
||||
|
||||
### Unit tests
|
||||
|
||||
- Gate returns `Ready` for published, safe, current output with artifact and permission.
|
||||
- Gate returns `Blocked` for PII/unredacted sensitive output.
|
||||
- Gate returns `Needs attention` or `Blocked` for limitations/not-ready output according to severity.
|
||||
- Gate returns `Not configured` for missing output/customer workspace.
|
||||
- Gate returns `Expired` for expired/superseded/archived/revoked output where repo-backed.
|
||||
- Gate returns `Unknown` when current safety cannot be determined.
|
||||
- Permission denial blocks even when output is otherwise safe.
|
||||
- Permission grant does not override unsafe output.
|
||||
|
||||
### Feature / HTTP route tests
|
||||
|
||||
- Safe customer-output download succeeds and audits.
|
||||
- PII/limited/not-ready output direct download is blocked.
|
||||
- Expired/superseded/missing output direct download is blocked.
|
||||
- Internal preview route/action requires internal/operator permission.
|
||||
- Customer/read-only user cannot access internal preview.
|
||||
- Rendered report does not expose customer download when gate blocks.
|
||||
- Management report PDF download, if customer-facing in implementation scope, respects the same gate.
|
||||
|
||||
### Filament / Livewire tests
|
||||
|
||||
- Unsafe customer output action is hidden/disabled/replaced with blocked state.
|
||||
- Blocking reason appears once.
|
||||
- `Open customer workspace` only appears when destination is actual Customer Review Workspace.
|
||||
- `Open review pack` is used for Review Pack detail.
|
||||
- Deprecated limited-download copy is absent from customer-facing surfaces.
|
||||
- Internal preview is secondary and permission-gated.
|
||||
|
||||
### Browser smoke
|
||||
|
||||
- Safe review pack: customer workspace opens and customer output downloads.
|
||||
- Unsafe review pack: customer output blocked and direct URL returns no file.
|
||||
- Internal preview, if present: labelled internal and secondary.
|
||||
- Dashboard CTA labelled `Open customer workspace` opens actual workspace.
|
||||
- Review Pack detail action is labelled `Open review pack` when destination is internal detail.
|
||||
|
||||
## Rollout And Deployment Considerations
|
||||
|
||||
- No env var changes expected.
|
||||
- No migrations expected.
|
||||
- No queue/scheduler changes expected.
|
||||
- No storage topology changes expected.
|
||||
- No Graph scopes or external API changes expected.
|
||||
- No Filament asset registration expected; `filament:assets` is not required unless implementation unexpectedly registers assets.
|
||||
- Staging validation should include safe and unsafe seeded review-output cases before production promotion.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
- Inventory-first / snapshots-second: PASS. The gate reads existing observed/review/report/artifact truth and does not create new external truth.
|
||||
- Read/write separation: PASS. This is access/read/download gating; no Microsoft tenant mutation.
|
||||
- Graph contract path: PASS. No Graph calls are expected; render/download must remain DB/storage-only.
|
||||
- Deterministic capabilities: PASS. Existing capability registry remains authoritative.
|
||||
- RBAC-UX: PASS. Membership 404, capability 403, UI not a security boundary, route enforcement required.
|
||||
- Workspace/tenant isolation: PASS. All output records remain workspace/environment-scoped before access.
|
||||
- OperationRun UX: PASS. No new starts or lifecycle changes.
|
||||
- Test governance: PASS. Unit/Feature/Filament/Browser lanes are explicit and bounded.
|
||||
- Proportionality: PASS with condition. One narrow gate/result adapter is justified by security and route enforcement; no persistence/framework.
|
||||
- Persisted truth: PASS. No new tables/entities/artifacts expected.
|
||||
- State behavior: PASS. Derived visible states change route/action behavior, not persisted lifecycle.
|
||||
- UI semantics: PASS. One visible customer output state, no duplicated badges.
|
||||
- Shared pattern first: PASS. Reuse existing readiness/disclosure/action guidance before any new helper.
|
||||
- Provider boundary: PASS. No provider seam introduced.
|
||||
- Filament-native UI: PASS. Use native/action semantics and existing Blade composition; no new styling system.
|
||||
- UI/Productization coverage: PASS with condition. Update existing UI coverage/page-report artifacts only if runtime UI changes materially.
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: Unit for gate derivation; Feature/HTTP for direct routes; Filament/Livewire for action state; Browser for rendered workflow/CTA proof.
|
||||
- **Affected validation lanes**: fast-feedback, confidence, browser.
|
||||
- **Why this lane mix is the narrowest sufficient proof**: route bypass and CTA destination cannot be proven by unit tests alone; browser is needed only for final trust path.
|
||||
- **Narrowest proving command(s)**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec392`
|
||||
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec392CustomerOutputGatingSmokeTest.php --compact`
|
||||
- `git diff --check`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: use existing review-output fixtures; avoid new global seed defaults.
|
||||
- **Expensive defaults or shared helper growth introduced?**: no; any new fixture must be opt-in.
|
||||
- **Heavy-family additions, promotions, or visibility changes**: one explicit Browser smoke.
|
||||
- **Surface-class relief / special coverage rule**: no standard-native relief for the route gate; customer-output direct route proof is mandatory.
|
||||
- **Closing validation and reviewer handoff**: reviewers should verify safe/unsafe route behavior, CTA destinations, no limitations-bearing customer download, and no app-code scope expansion beyond listed surfaces.
|
||||
- **Budget / baseline / trend follow-up**: none.
|
||||
- **Review-stop questions**: Is the route gate shared? Is internal preview separate? Are completed specs untouched? Are broad report/workspace rewrites avoided?
|
||||
- **Escalation path**: document-in-feature for contained exceptions; follow-up-spec only for structural delivery gaps.
|
||||
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage.
|
||||
- **Why no dedicated follow-up spec is needed**: this is the dedicated narrow follow-up for fresh customer-output safety evidence; future artifact lifecycle/report runtime/customer portal work is separate.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1 - Repo Truth And Action Inventory
|
||||
|
||||
Inventory all customer-output open/download actions and routes, classify each as customer-facing or internal, and record current label, destination, visibility condition, authorization, and direct-route behavior.
|
||||
|
||||
### Phase 2 - Gate Contract And Tests
|
||||
|
||||
Add tests first for the gate states and route expectations. Choose the narrowest gate home after confirming current helper coverage.
|
||||
|
||||
### Phase 3 - Route Enforcement
|
||||
|
||||
Apply the gate to customer-facing output routes before streaming/rendering output. Preserve membership/capability semantics and audit logging.
|
||||
|
||||
### Phase 4 - UI Label And Action Correction
|
||||
|
||||
Update Customer Review Workspace, Review Pack, Environment Review, Environment Dashboard, Evidence Overview, and rendered report actions only where they are misleading or bypass the gate.
|
||||
|
||||
### Phase 5 - Internal Preview Separation
|
||||
|
||||
If limited artifacts remain accessible, relabel as `Download internal preview`, demote to secondary, protect with internal permission, and remove from customer-facing/default surfaces.
|
||||
|
||||
### Phase 6 - Customer-Safe Disclosure And Proof Demotion
|
||||
|
||||
Ensure internal proof links, OperationRun details, raw metadata, and diagnostics are secondary/capability-gated by default on customer-facing surfaces.
|
||||
|
||||
### Phase 7 - Validation And Browser Evidence
|
||||
|
||||
Run targeted tests, browser smoke, `pint --dirty`, `git diff --check`, and update active spec/page-report artifacts as required by UI-COV.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
specs/392-customer-output-gating-review-pack-navigation/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── tasks.md
|
||||
└── checklists/
|
||||
└── requirements.md
|
||||
```
|
||||
|
||||
Likely runtime/test surfaces:
|
||||
|
||||
```text
|
||||
apps/platform/app/
|
||||
├── Http/Controllers/
|
||||
├── Filament/Pages/Reviews/
|
||||
├── Filament/Pages/Monitoring/
|
||||
├── Filament/Resources/
|
||||
└── Support/
|
||||
|
||||
apps/platform/resources/views/
|
||||
├── filament/pages/reviews/
|
||||
└── review-packs/
|
||||
|
||||
apps/platform/tests/
|
||||
├── Unit/
|
||||
├── Feature/
|
||||
└── Browser/
|
||||
```
|
||||
|
||||
No new base folders are expected.
|
||||
399
specs/392-customer-output-gating-review-pack-navigation/spec.md
Normal file
399
specs/392-customer-output-gating-review-pack-navigation/spec.md
Normal file
@ -0,0 +1,399 @@
|
||||
# Feature Specification: Spec 392 - Customer Output Gating & Review Pack Navigation v1
|
||||
|
||||
**Feature Branch**: `392-customer-output-gating-review-pack-navigation`
|
||||
**Created**: 2026-06-20
|
||||
**Status**: Draft
|
||||
**Type**: Bugfix / trust and safety / customer-output productization
|
||||
**Runtime posture**: Narrow enforcement and navigation hardening over existing review, review-pack, rendered-report, management-report, and Customer Review Workspace output paths. No broad workspace rebuild, no new customer portal, no new persisted readiness truth, and no review-pack generator rewrite.
|
||||
**Input**: User-provided Spec 392 draft plus repo truth from Specs 342, 347, 351, 372, 379, 385, 390, the browser productization bug audit, and current review-pack/customer-workspace runtime references.
|
||||
|
||||
## Dependencies And Historical Context
|
||||
|
||||
Spec 392 is a follow-up over already repo-real customer-review and review-output work:
|
||||
|
||||
- Spec 342 - Customer Review Workspace final consumption productization.
|
||||
- Spec 347 - Review Pack output contract and readiness semantics.
|
||||
- Spec 349 - Customer Review Workspace output resolution guidance.
|
||||
- Spec 351 - Review output resolve actions.
|
||||
- Spec 372 - Customer/auditor surface safety pass.
|
||||
- Spec 379 - Management Report PDF runtime.
|
||||
- Spec 385 - Evidence review readiness.
|
||||
- Spec 390 - Restore readiness resolution adapter.
|
||||
- `specs/browser-productization-bug-audit/browser-bug-report.md`, especially BUG-007, which records an `Open customer workspace` CTA opening Review Pack detail.
|
||||
|
||||
Repo-truth adjustment against the user draft:
|
||||
|
||||
- Broad Customer Review Workspace v1 completion is completed/historical; this spec must not reopen it.
|
||||
- A review-pack output readiness derivation already exists under `App\Support\ReviewPacks\ReviewPackOutputReadiness`; this spec should reuse or wrap existing readiness truth before adding any new support object.
|
||||
- Existing report profile/disclosure structures already distinguish customer and internal report profiles; this spec must not add a second report profile framework.
|
||||
- Current routes include `/admin/review-packs/{reviewPack}/download`, `/admin/review-packs/{reviewPack}/report`, and `/admin/management-report-pdfs/{storedReport}/download`; these are in scope only where they expose customer-facing output or customer-safe labels.
|
||||
- Current copy still includes a deprecated limited-download concept; Spec 392 removes that customer-output behavior and replaces any authorized internal access with the canonical internal-preview concept.
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Customer-facing output is a trust boundary. TenantPilot currently has reachable output and navigation paths where customer-safe labels can be stronger than the gate enforced by the route or destination.
|
||||
- **Today's failure**: A user may see a customer-facing download/open action for output with limitations, PII risk, incomplete evidence, missing output, expired/superseded output, or internal-only semantics; a CTA labelled `Open customer workspace` may route to internal Review Pack detail instead of the actual workspace.
|
||||
- **User-visible improvement**: Operators can trust that customer output actions are either truly safe and routed correctly, or clearly blocked with one reason and a safe next action. Customer workspace, review pack, report, and internal preview actions stop competing or misleading by label.
|
||||
- **Smallest enterprise-capable version**: Inventory all customer-output open/download actions, introduce or consolidate one customer-output gate over existing readiness/disclosure truth, enforce it at route/controller/action level, and correct misleading labels on the existing surfaces only.
|
||||
- **Explicit non-goals**: No broad Customer Review Workspace redesign, no external customer portal, no new review publication workflow, no generator rewrite, no new report renderer, no new persisted readiness table, no new dashboard, no new evidence cards, no new operation-run panels, no new customer-facing technical proof surfaces.
|
||||
- **Permanent complexity imported**: One narrow gate/service or result object if existing readiness guidance cannot serve; focused Unit/Feature/Filament/Browser tests; localized copy updates; UI coverage/page-report updates if implementation changes reachable surfaces. No new tables, no cross-domain readiness framework, and no broad status taxonomy.
|
||||
- **Why now**: The product has completed enough customer review and output productization that the remaining risk is no longer feature absence; it is unsafe availability and misleading customer-facing affordances.
|
||||
- **Why not local**: A local label patch would leave direct-route downloads, report pages, and other surfaces able to bypass the same safety decision. The security boundary must be shared by UI and route enforcement.
|
||||
- **Approval class**: Core Enterprise.
|
||||
- **Red flags triggered**: Customer-facing trust language, route-level access boundary, derived state/gate semantics, cross-surface action labels, and PII/output disclosure. Defense: the slice is security and auditability-critical, derived from existing truth, route-enforced, and explicitly forbids new persistence or a generic readiness engine.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||
- **Decision**: approve.
|
||||
|
||||
## Candidate Source And Completed-Spec Guardrail
|
||||
|
||||
- **Selected candidate**: Customer Output Gating & Review Pack Navigation v1.
|
||||
- **Source locations**:
|
||||
- User-provided draft: `/Users/ahmeddarrazi/.codex/attachments/badee408-00ed-499e-b5c2-f6f48278b29a/pasted-text.txt`
|
||||
- Browser audit evidence: `specs/browser-productization-bug-audit/browser-bug-report.md`
|
||||
- Roadmap relationship: `docs/product/roadmap.md` states customer review workspace v1 is completed/historical, but customer-safe review consumption remains a current productization and trust concern when fresh repo evidence exists.
|
||||
- Candidate relationship: `docs/product/spec-candidates.md` treats broad Customer Review Workspace v1 as completed and allows only narrow future polish/fresh-evidence follow-ups.
|
||||
- **Completed-spec check**:
|
||||
- No `specs/392-*` package existed before this prep.
|
||||
- Specs 342, 347, 351, and 372 contain completed-task, validation, smoke, or implementation-history signals and are read-only historical context.
|
||||
- Spec 379 is runtime-gated management-report context, not a target to reopen here.
|
||||
- The browser productization audit is an evidence source package and remains read-only.
|
||||
- **Boundary against related completed specs**:
|
||||
- Spec 342/372 customer-workspace and customer/auditor surface productization must not be replayed.
|
||||
- Spec 347 output-readiness contract must be reused or wrapped, not replaced.
|
||||
- Spec 351 resolution actions may inform safe next-action wording, but this spec does not rebuild the resolution guidance framework.
|
||||
- **Close alternatives deferred**:
|
||||
- Governance Artifact Lifecycle & Retention runtime: separate lifecycle/retention product gap.
|
||||
- Management Report PDF staging/Dokploy validation: follow current Spec 379 runtime gate.
|
||||
- External customer portal or external consumption: separate product decision.
|
||||
- Broad customer-facing localization polish: copy QA lane only after safety semantics are stable.
|
||||
- Report profile/disclosure framework expansion: out of scope because existing profile/disclosure structures already exist.
|
||||
- **Smallest viable implementation slice**: Existing customer-output actions and routes only: Review Pack download/report, management-report download where customer-facing, Customer Review Workspace output actions, Review Pack/Environment Review action labels, Environment Dashboard customer workspace CTA, Evidence Overview customer-workspace links, and direct-route bypass tests.
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: Workspace canonical view plus environment-owned review/output artifacts.
|
||||
- **Primary Routes / Surfaces**:
|
||||
- `/admin/reviews/workspace` via `App\Filament\Pages\Reviews\CustomerReviewWorkspace`
|
||||
- `/admin/review-packs/{reviewPack}/download` via `App\Http\Controllers\ReviewPackDownloadController`
|
||||
- `/admin/review-packs/{reviewPack}/report` via `App\Http\Controllers\ReviewPackRenderedReportController`
|
||||
- `/admin/management-report-pdfs/{storedReport}/download` where the report is presented as customer-facing output
|
||||
- `App\Filament\Resources\ReviewPackResource`
|
||||
- `App\Filament\Resources\EnvironmentReviewResource`
|
||||
- `App\Support\EnvironmentDashboard\EnvironmentDashboardSummaryBuilder`
|
||||
- `App\Filament\Pages\Monitoring\EvidenceOverview`
|
||||
- **Data Ownership**:
|
||||
- `EnvironmentReview` remains released-review truth.
|
||||
- `ReviewPack` remains export artifact truth.
|
||||
- `StoredReport` remains report artifact truth.
|
||||
- Existing review-pack/readiness/disclosure summaries remain derived truth.
|
||||
- `OperationRun` remains execution/proof truth only; it is not customer-safety truth.
|
||||
- No new table, persisted entity, or durable readiness record is expected.
|
||||
- **RBAC**:
|
||||
- Workspace membership and managed-environment entitlement are mandatory before revealing customer-output records.
|
||||
- Existing capabilities remain authoritative, especially `REVIEW_PACK_VIEW`, `ENVIRONMENT_REVIEW_VIEW`, evidence/report view capabilities, and any stored-report download capability.
|
||||
- Non-member or cross-workspace/environment access remains deny-as-not-found.
|
||||
- Member-without-capability remains 403 for direct action execution, with disabled/hidden UI per existing enforcement helpers.
|
||||
- Gate-safe state is required in addition to permission; permission alone never permits unsafe customer-facing output.
|
||||
|
||||
For canonical-view specs:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: Customer Review Workspace remains workspace-wide with explicit `environment_id` page filtering only. Spec 392 must not revive `/admin/t`, hidden tenant context, legacy query aliases, or remembered environment switcher authority.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: All review, review-pack, stored-report, evidence, and report download/open routes must resolve records through current workspace and managed-environment entitlement before checking customer-output safety.
|
||||
- **Management Report PDF customer-facing decision**: Treat a management-report PDF route/action as customer-facing when it is labelled, linked, or positioned as customer/auditor review output from Customer Review Workspace, Review Pack, Environment Review, Environment Dashboard, Evidence Overview, or rendered-report flows. Treat it as internal-only only when current repo truth shows the route is reachable solely from operator/audit/technical surfaces and its label/copy does not imply customer delivery. Implementation must record this decision before changing or excluding the route.
|
||||
|
||||
## UI Surface Impact *(mandatory - UI-COV-001)*
|
||||
|
||||
Does this spec add, remove, rename, or materially change any reachable UI surface?
|
||||
|
||||
- [ ] No UI surface impact
|
||||
- [x] Existing page changed
|
||||
- [ ] New page/route added
|
||||
- [x] Navigation changed
|
||||
- [ ] Filament panel/provider surface changed
|
||||
- [ ] New modal/drawer/wizard/action added
|
||||
- [x] New table/form/state added
|
||||
- [x] Customer-facing surface changed
|
||||
- [ ] Dangerous action changed
|
||||
- [x] Status/evidence/review presentation changed
|
||||
- [x] Workspace/environment context presentation changed
|
||||
|
||||
## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact")*
|
||||
|
||||
| Route/page/surface | Archetype | Design depth | Repo-truth level | Existing pattern reused | Screenshot / audit need | Customer-safe review | Dangerous-action review |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Customer Review Workspace | Existing strategic customer review surface | Strategic Surface | repo-verified + browser-tested historical context | Specs 342/347/372 customer-safe hierarchy | Browser screenshot if action hierarchy changes; update existing page report if material | yes | no new dangerous action |
|
||||
| Review Pack download/report | Existing artifact output route/detail | Domain Pattern Surface | repo-verified | Spec 347 output readiness, existing signed download route | Route tests required; screenshot only if rendered report/action copy changes | yes | no new dangerous action |
|
||||
| Management report PDF download where customer-facing | Existing report artifact route | Domain Pattern Surface | repo-verified current runtime gate | Spec 379 report runtime gate and disclosure posture | Route tests if in scope | yes | no new dangerous action |
|
||||
| Environment Dashboard customer-workspace CTA | Existing environment dashboard action | Decision/workbench context | repo-verified BUG-007 source | Existing CustomerReviewWorkspace URL helper and ReviewPackResource URLs | Browser smoke required for CTA truth | yes | no new dangerous action |
|
||||
| Evidence Overview customer-workspace links | Existing evidence monitoring context | Secondary Context / Evidence | repo-verified | Existing CustomerReviewWorkspace URL helper | Browser or Feature coverage if touched | yes | no new dangerous action |
|
||||
|
||||
- **Coverage files updated or explicitly not needed**:
|
||||
- [ ] `docs/ui-ux-enterprise-audit/route-inventory.md`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md`
|
||||
- [x] `docs/ui-ux-enterprise-audit/page-reports/...` when implementation materially changes reachable page behavior
|
||||
- [ ] `docs/ui-ux-enterprise-audit/strategic-surfaces.md`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/grouped-follow-up-candidates.md`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/unresolved-pages.md`
|
||||
- [ ] `N/A - no reachable UI surface impact`
|
||||
- **No-impact rationale when applicable**: N/A.
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory)*
|
||||
|
||||
- **Cross-cutting feature?**: yes.
|
||||
- **Interaction class(es)**: status messaging, action links, customer-output downloads, report viewers, review-pack viewers, dashboard cards, evidence links, route authorization.
|
||||
- **Systems touched**:
|
||||
- `ReviewPackOutputReadiness`
|
||||
- `ReviewPackOutputResolutionGuidance`
|
||||
- `ReportDisclosurePolicy`
|
||||
- `CustomerReviewWorkspace`
|
||||
- `ReviewPackDownloadController`
|
||||
- `ReviewPackRenderedReportController`
|
||||
- `ReviewPackResource`
|
||||
- `EnvironmentReviewResource`
|
||||
- `EnvironmentDashboardSummaryBuilder`
|
||||
- `EvidenceOverview`
|
||||
- localization keys for output/download/customer workspace labels
|
||||
- **Existing pattern(s) to extend**: Spec 347 output-readiness contract, Spec 351 resolution guidance action semantics, Spec 372 customer/auditor disclosure hierarchy, existing capability and policy checks, existing signed download protections.
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: Existing review-pack readiness/disclosure helpers first. A new `CustomerOutputGate`-style service is allowed only if it centralizes cross-route enforcement and absorbs existing readiness/disclosure truth instead of duplicating it.
|
||||
- **Why the existing shared path is sufficient or insufficient**: Existing readiness helpers derive whether output has limitations, PII, export availability, and customer-safe state, but current direct routes and action labels do not yet consistently consume one route-enforced customer-output decision.
|
||||
- **Allowed deviation and why**: One narrow customer-output gate/result object is allowed as a security boundary if existing helpers cannot safely serve both UI affordances and direct route authorization. It must stay scoped to customer-output open/download decisions and must not become a generic governance readiness framework.
|
||||
- **Consistency impact**: Customer output state, primary action, blocking reason, internal preview label, route enforcement, and dashboard/review-pack/customer-workspace CTAs must describe the same decision.
|
||||
- **Review focus**: Block any duplicated page-local readiness logic, any active customer-facing download with limitations, any `Open customer workspace` action routed to internal detail, and any direct route that streams blocked output.
|
||||
|
||||
## OperationRun UX Impact *(mandatory)*
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: Existing proof/deep-link display only; no new operation start, completion, queueing, dedupe, terminal notification, or lifecycle behavior is expected.
|
||||
- **Shared OperationRun UX contract/layer reused**: Existing OperationRun links and artifact proof routes only.
|
||||
- **Delegated start/completion UX behaviors**: N/A for new operation starts.
|
||||
- **Local surface-owned behavior that remains**: Customer-output blocking copy may mention output state, but not raw operation internals.
|
||||
- **Queued DB-notification policy**: N/A.
|
||||
- **Terminal notification path**: unchanged.
|
||||
- **Exception required?**: none.
|
||||
|
||||
## Provider Boundary / Platform Core Check *(mandatory)*
|
||||
|
||||
- **Shared provider/platform boundary touched?**: no new provider seam.
|
||||
- **Boundary classification**: platform-core customer-output boundary over existing review/report artifacts.
|
||||
- **Seams affected**: customer-facing route labels, output safety checks, and report/review-pack artifact access.
|
||||
- **Neutral platform terms preserved or introduced**: customer output, customer workspace, review pack, internal preview, blocking reason, output state.
|
||||
- **Provider-specific semantics retained and why**: Provider-specific facts may remain inside existing customer-safe review/report content only; they must not become gate vocabulary.
|
||||
- **Why this does not deepen provider coupling accidentally**: No Graph contracts, provider credentials, provider connections, provider identifiers, or Microsoft-specific scopes are introduced.
|
||||
- **Follow-up path**: none unless implementation finds provider-specific disclosure leakage, which should become a separate follow-up spec.
|
||||
|
||||
## UI / Surface Guardrail Impact
|
||||
|
||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||
|---|---:|---|---|---|---:|---|
|
||||
| Customer output actions on Customer Review Workspace | yes | Existing Filament page plus Blade composition | customer-safe output, action links | page payload, route, URL-query | no | Existing route only |
|
||||
| Review Pack download/report labels and access | yes | Filament resource/detail plus HTTP controller | artifact viewer, download action | detail, route | no | No generator rewrite |
|
||||
| Environment Dashboard customer-workspace CTA | yes | Existing dashboard summary/action payload | dashboard card action links | page payload, navigation | no | Correct label/destination only |
|
||||
| Evidence Overview customer-workspace link labels | yes if touched | Existing Filament page | evidence/report viewer links | page payload, navigation | no | Keep proof secondary |
|
||||
| Direct customer-output routes | no visible UI by themselves | N/A | route authorization | route | no | Security enforcement only |
|
||||
|
||||
## Decision-First Surface Role
|
||||
|
||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Customer Review Workspace output actions | Primary Decision Surface | Decide whether output can be opened/downloaded for a customer or needs attention | one customer output state, one primary next action, concise reason | review pack detail, evidence, audit trail, technical details when authorized | Primary for customer-safe consumption | customer review handoff | removes guessing from multiple badges/actions |
|
||||
| Review Pack detail/download | Secondary Context / Artifact Surface | Verify artifact readiness and access the correct output type | artifact state, customer-output state, download/internal preview availability | operation proof, metadata, report details | Secondary because it verifies one artifact | output verification | prevents unsafe artifact sharing |
|
||||
| Environment Dashboard customer-workspace CTA | Secondary Context / Navigation Surface | Navigate from environment posture to the correct customer review destination | truthful CTA label and disabled/unavailable state | review pack detail if labelled as internal artifact | Not primary; it is a launcher | dashboard-to-review navigation | removes wrong-destination confusion |
|
||||
|
||||
## Audience-Aware Disclosure
|
||||
|
||||
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Customer Review Workspace | customer-read-only, operator-MSP, support where authorized | customer output state, reason, impact, next action | review/evidence/report proof links secondary | raw payloads, IDs, source keys, operation internals | state-specific customer-safe action or blocker action | internal preview and technical proof | one state label; details explain rather than restate |
|
||||
| Review Pack / rendered report | operator-MSP, auditor/customer consumer, support where authorized | whether the artifact is customer-safe, blocked, expired, or internal preview only | generation proof, metadata, operation link | raw ZIP/report internals and debug detail | download customer output only when ready | internal preview, technical metadata | label and route agree |
|
||||
| Environment Dashboard link | operator-MSP | truthful route label and enabled/disabled state | none by default | none | open customer workspace or open review pack, not both as the same label | internal detail when not labelled as such | destination determines label |
|
||||
|
||||
## UI/UX Surface Classification
|
||||
|
||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Customer Review Workspace output block | Utility / Workspace Decision | Strategic review hub | open safe output or resolve blocker | explicit primary action | forbidden | secondary proof links below | none in scope | `/admin/reviews/workspace` | existing review/pack/report routes | workspace and explicit environment filter | Customer output | safety state and reason | none |
|
||||
| Review Pack detail/download | Utility / Artifact Detail | Artifact proof/download | download customer output or internal preview | detail header/action | current repo behavior | technical proof secondary | existing destructive actions unchanged/out of scope | existing review-pack collection | existing review-pack detail/download/report | workspace/environment context | Review pack | customer-output gate state | none |
|
||||
| Environment Dashboard CTA | Dashboard Signal | Navigation action | open correct review surface | explicit action payload | N/A | review pack detail only as `Open review pack` | none | environment dashboard | customer workspace or review pack detail | environment context | Customer workspace / Review pack | route truth | none |
|
||||
|
||||
## UI Action Matrix
|
||||
|
||||
| Action label | Surface(s) | Destination / effect | Primary or secondary | Visible / enabled rule | Server-side enforcement | Destructive? |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `Open customer workspace` | Environment Dashboard, Evidence Overview if touched, Environment Review / Review Pack navigation if present | Actual Customer Review Workspace prefiltered to the environment | Primary only when the workspace is the intended next customer-safe destination | Show or enable only when destination is the workspace and workspace/environment entitlement is established; otherwise use a blocked/unavailable or review action | Workspace membership, managed-environment entitlement, capability checks, and customer-output gate state where output access is implied | No |
|
||||
| `Open review pack` | Review Pack links from dashboard, evidence, environment review, and related detail surfaces | Review Pack detail / internal artifact context | Secondary context or artifact inspection | Use whenever the destination is internal Review Pack detail; never label this destination as customer workspace | Workspace membership, managed-environment entitlement, and Review Pack view capability | No |
|
||||
| `Download customer output` | Customer Review Workspace, Review Pack detail/report, rendered report, and Management Report PDF only when customer-facing | Stream or open customer-safe output | Primary only when state is `Ready` | Visible/enabled only when the gate returns `Ready`; hidden/disabled/replaced with blocker copy otherwise | Same customer-output gate before streaming/rendering/signing/redirecting, plus membership, entitlement, capability, artifact status, expiry, and file checks | No |
|
||||
| `Download internal preview` | Review Pack detail/report or internal operator surfaces only | Stream limitations-bearing or internal-only artifact for operators | Secondary | Visible only for authorized internal/operator users when limited output remains useful; hidden from customer/read-only views | Internal/operator capability plus internal-preview gate; must not reuse customer-output permission alone | No |
|
||||
| `View audit trail` / `View technical details` | Review Pack detail, rendered report, Evidence Overview, Customer Review Workspace secondary proof areas | Open internal proof, audit, or diagnostic detail | Secondary/diagnostic | Gated or collapsed by default on customer-facing surfaces; never presented as customer output | Existing audit/detail authorization and workspace/environment entitlement | No |
|
||||
| `Review blockers` / `Prepare output` / existing regenerate action | Customer Review Workspace or Review Pack detail when output is blocked/not configured | Existing blocker review or output-preparation flow | Primary only when it is the safest next action and already supported | Use instead of unsafe customer download when output is blocked, missing, or needs attention | Existing capability/policy checks for the underlying action; destructive/high-impact existing actions remain out of scope and must keep confirmation | Existing behavior unchanged |
|
||||
|
||||
## Operator Surface Contract
|
||||
|
||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Customer output gate across review surfaces | MSP/operator, reviewer, auditor facilitator | decide whether output may be shown/downloaded for customers | cross-surface output boundary | Is this output customer-safe right now, and where should I go next? | Ready / Needs attention / Blocked / Not configured / Expired / Unknown, one reason, one next action | evidence, operation proof, raw report metadata, internal preview | publication state, artifact availability, safety/disclosure, freshness, authorization | TenantPilot only for gating; no Microsoft tenant mutation | Open customer workspace, Download customer output, Review blockers, Prepare output, Regenerate output if already supported | none introduced |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no.
|
||||
- **New persisted entity/table/artifact?**: no.
|
||||
- **New abstraction?**: maybe one narrow customer-output gate/result object.
|
||||
- **New enum/state/reason family?**: derived user-facing state labels only; no persisted enum/status family expected.
|
||||
- **New cross-domain UI framework/taxonomy?**: no.
|
||||
- **Current operator problem**: Customer-facing output actions can imply safe delivery even when readiness/disclosure state or route destination does not support that claim.
|
||||
- **Existing structure is insufficient because**: Existing readiness/disclosure helpers are not a route-enforced access boundary and do not currently force every CTA/download/open path through one safety decision.
|
||||
- **Narrowest correct implementation**: Reuse existing readiness and disclosure structures inside one customer-output gate consumed by UI and direct routes; correct labels to match destinations; block unsafe downloads.
|
||||
- **Ownership cost**: One small support object/service if needed, localized copy, focused tests, and browser smoke. No new database or broad framework ownership.
|
||||
- **Alternative intentionally rejected**: Copy-only rename, page-local hidden/disabled buttons, new persisted readiness table, new output workflow, or broad Customer Review Workspace redesign.
|
||||
- **Release truth**: current-release truth.
|
||||
|
||||
### Compatibility posture
|
||||
|
||||
This feature assumes a pre-production environment.
|
||||
|
||||
Backward compatibility, legacy aliases, migration shims, stale signed customer-output downloads, and compatibility-specific tests are out of scope unless explicitly required by implementation-time repo truth. Customer-safety replacement is preferred over preserving unsafe labels.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Unit for customer-output gate derivation; Feature/HTTP for direct route blocking and safe downloads; Filament/Livewire for action visibility/labels; Browser for safe/unsafe customer review output path and CTA destination truth.
|
||||
- **Validation lane(s)**: fast-feedback + confidence + browser.
|
||||
- **Why this classification and these lanes are sufficient**: Gate behavior can be proven with focused service tests, direct-route bypass with HTTP tests, Filament action state with Livewire tests, and final user-visible trust boundaries with one bounded browser smoke.
|
||||
- **New or expanded test families**: `Spec392CustomerOutputGateTest`, `Spec392CustomerOutputRouteGateTest`, `Spec392CustomerOutputActionLabelsTest`, and one bounded browser smoke if UI/user-facing changes land.
|
||||
- **Fixture / helper cost impact**: reuse existing review-output browser fixture and current review/review-pack/stored-report factories; do not widen global seed defaults.
|
||||
- **Heavy-family visibility / justification**: one browser smoke is explicit because this is a customer-facing trust boundary and includes CTA destination verification.
|
||||
- **Special surface test profile**: global-context-shell + customer-safe strategic review surface + artifact download route.
|
||||
- **Standard-native relief or required special coverage**: no standard-native relief for route gate; direct-route and customer-facing output proof are mandatory.
|
||||
- **Reviewer handoff**: verify no unsafe output streams through direct routes, no deprecated limited-download copy remains customer-facing, and no `Open customer workspace` route opens internal Review Pack detail.
|
||||
- **Budget / baseline / trend impact**: none expected beyond one bounded browser test.
|
||||
- **Escalation needed**: `document-in-feature` for contained label/gate exceptions; `follow-up-spec` only if implementation discovers structural report-delivery or customer-portal gaps.
|
||||
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage.
|
||||
- **Planned validation commands**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec392`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter='CustomerReviewWorkspace|ReviewPack|StoredReport'`
|
||||
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec392CustomerOutputGatingSmokeTest.php --compact`
|
||||
- `cd apps/platform && ./vendor/bin/sail pint --dirty`
|
||||
- `git diff --check`
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Block Unsafe Customer Output Everywhere (Priority: P1)
|
||||
|
||||
An operator or reviewer attempts to open or download customer-facing review output. TenantPilot allows it only when the customer-output gate returns a ready/safe state, and blocks direct URL access otherwise.
|
||||
|
||||
**Why this priority**: This is the core trust and safety boundary; UI hiding is not enough.
|
||||
|
||||
**Independent Test**: Feature/HTTP tests create ready, PII-limited, missing, expired, internal-only, and unknown output scenarios and assert direct customer-facing routes stream only the ready output.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a review pack with unresolved PII or internal-only output, **When** an authorized user opens the customer-facing download URL directly, **Then** no file is returned and the response is 403 or safe redirect with a concise block reason.
|
||||
2. **Given** a published, customer-safe review pack with a generated artifact and no blockers, **When** an authorized user downloads customer output, **Then** the correct artifact is returned and an audit event is recorded.
|
||||
3. **Given** a review pack is missing, failed, expired, archived, superseded, revoked, or stale beyond allowed currentness, **When** a customer-facing open/download route is used, **Then** the route blocks the output and does not leak artifact contents.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Use Truthful Review Pack And Customer Workspace Navigation (Priority: P1)
|
||||
|
||||
An operator moves between dashboard, evidence, review, review pack, and customer workspace surfaces. Each CTA label matches its actual destination and customer-safety posture.
|
||||
|
||||
**Why this priority**: Mislabelled navigation causes operators to trust or share the wrong surface.
|
||||
|
||||
**Independent Test**: Filament/Livewire and browser tests assert `Open customer workspace` routes only to `CustomerReviewWorkspace`, while Review Pack detail uses `Open review pack`.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an environment dashboard card links to a ready review pack detail, **When** the link is rendered, **Then** its label is `Open review pack`, not `Open customer workspace`.
|
||||
2. **Given** the label is `Open customer workspace`, **When** the user clicks it, **Then** it opens the actual Customer Review Workspace prefiltered to the environment.
|
||||
3. **Given** customer workspace is missing or blocked, **When** the page renders, **Then** no active primary CTA says `Open customer workspace`; the UI shows a truthful unavailable or prepare/review action.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Separate Internal Preview From Customer Output (Priority: P1)
|
||||
|
||||
An internal operator may still access an internal preview when authorized, but the UI and route never present it as customer-ready output.
|
||||
|
||||
**Why this priority**: Internal preview is useful, but only if it cannot be mistaken for customer output.
|
||||
|
||||
**Independent Test**: Unit/Feature/Filament tests assert limitations-bearing output maps to internal-preview or blocked behavior, never customer-output download.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a generated artifact has limitations or unresolved blockers, **When** an internal operator has the required permission, **Then** any allowed access is labelled `Download internal preview` and remains secondary.
|
||||
2. **Given** a customer-facing/read-only user views the same state, **When** actions render, **Then** internal preview is hidden or unavailable and customer output remains blocked.
|
||||
3. **Given** output is limitations-bearing, **When** rendered reports or review-pack details show actions, **Then** the page does not show customer-output download wording or equivalent customer-safe wording.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Keep Customer-Facing Surfaces Free Of Internal Proof By Default (Priority: P2)
|
||||
|
||||
Customer-facing review output surfaces keep internal proof, raw metadata, operation links, and technical diagnostics secondary or capability-gated by default.
|
||||
|
||||
**Why this priority**: It prevents accidental internal proof leakage while preserving audit depth for operators.
|
||||
|
||||
**Independent Test**: Feature/Browser tests render customer-facing surfaces and assert no raw IDs, operation internals, source keys, fingerprints, or report-generation metadata are default-visible.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a customer-facing output surface renders, **When** the user has ordinary review permissions, **Then** internal proof links and technical metadata are not default-visible.
|
||||
2. **Given** an authorized operator needs proof, **When** they use an internal technical/audit action, **Then** proof remains reachable as internal detail and not labelled customer output.
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
- **FR-001**: Customer-facing output open/download actions MUST be blocked unless the canonical customer-output gate returns `Ready`.
|
||||
- **FR-002**: The gate MUST evaluate existing publication state, artifact availability, readiness/disclosure state, PII/redaction blockers, internal-only flags, expired/superseded/revoked/archived state where repo-backed, customer workspace availability, user authorization, workspace/environment scope, and destination type.
|
||||
- **FR-003**: Every customer-facing open/download route MUST enforce the same gate decision before streaming a file, rendering a customer-facing report, issuing a signed URL, or redirecting to customer output.
|
||||
- **FR-004**: Direct route access MUST return 403, 404 deny-as-not-found, or a safe admin redirect without output content when blocked.
|
||||
- **FR-005**: Deprecated limited-download copy MUST NOT appear as a customer-facing output action. If an internal artifact remains accessible, it MUST be labelled `Download internal preview`.
|
||||
- **FR-006**: `Open customer workspace` MUST route only to the actual Customer Review Workspace. Internal Review Pack detail MUST use `Open review pack`.
|
||||
- **FR-007**: Affected surfaces MUST show only one top-level customer output state by default: `Ready`, `Needs attention`, `Blocked`, `Not configured`, `Expired`, or `Unknown`.
|
||||
- **FR-008**: Blocked states MUST show one concise customer-safe reason and avoid implementation terms such as artifact payload, detector, source key, operation failure, or raw policy internals in default copy.
|
||||
- **FR-009**: Internal preview routes/actions MUST require internal/operator authorization and MUST NOT appear by default on customer-facing surfaces.
|
||||
- **FR-010**: Route and UI checks MUST both enforce workspace membership, managed-environment entitlement, capability authorization, and customer-output gate-safe state.
|
||||
- **FR-011**: Customer-facing surfaces MUST not expose internal proof links, operation internals, raw evidence, source keys, fingerprints, raw payloads, baseline internals, or report-generation metadata by default.
|
||||
- **FR-012**: The implementation MUST reuse existing readiness/disclosure truth before adding any new derived helper.
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
- **NFR-001 - Security**: Deny cross-workspace/environment access as not found and avoid leaking blocked artifact existence to non-members.
|
||||
- **NFR-002 - Auditability**: Successful customer-output downloads and any internal-preview downloads MUST preserve or add audit entries with safe metadata only.
|
||||
- **NFR-003 - Performance**: Gate evaluation MUST be DB-backed and must not call Microsoft Graph or remote services during render or route access.
|
||||
- **NFR-004 - Simplicity**: No new persisted readiness truth or generalized readiness framework is allowed unless spec/plan are amended.
|
||||
- **NFR-005 - Accessibility/UX**: Disabled/blocked output actions must provide concise helper text or reason copy for entitled users.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Customer portal, external user invitation, or customer authentication.
|
||||
- Review publication workflow redesign.
|
||||
- Review-pack ZIP contract or generator rewrite beyond minimal data required by the gate.
|
||||
- Management Report PDF renderer validation or Dokploy runtime enablement.
|
||||
- Governance artifact lifecycle/retention runtime.
|
||||
- New dashboards, new cards, new evidence panels, new proof pages, or new operation-run surfaces.
|
||||
- New database tables, migrations, packages, queue families, scheduler changes, storage topology changes, or Graph scopes unless implementation proves an unavoidable need and spec/plan are updated first.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Existing `ReviewPackOutputReadiness`, `ReviewPackOutputResolutionGuidance`, and `ReportDisclosurePolicy` can provide most customer-output safety signals.
|
||||
- Existing policies and capabilities already cover Review Pack, Environment Review, Evidence, Stored Report, and Customer Review Workspace access.
|
||||
- Current product is pre-production, so unsafe labels can be replaced rather than preserved for compatibility.
|
||||
- The safest V1 is route-gate-first and UI-copy-second.
|
||||
|
||||
## Risks
|
||||
|
||||
- Existing tests may expect limitations-bearing output to remain downloadable from customer-facing routes.
|
||||
- Some surfaces may duplicate readiness logic and need careful consolidation without broad refactor.
|
||||
- Management Report PDF runtime is gated; implementation must not make it appear production-ready.
|
||||
- Internal-preview access may require a clear capability mapping; if current capabilities cannot express it, the implementation must update spec/plan before expanding scope.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- 100% of affected customer-facing output routes call the same gate or equivalent shared policy before returning output.
|
||||
- 0 customer-facing occurrences of deprecated limited-download copy remain.
|
||||
- 0 `Open customer workspace` CTAs route to Review Pack detail or other internal detail pages.
|
||||
- Targeted Feature/HTTP/Filament tests cover safe, unsafe, direct-route, label, and permission cases.
|
||||
- Browser smoke proves one safe path, one unsafe/blocked path, and the corrected customer-workspace CTA.
|
||||
|
||||
## Follow-Up Spec Candidates
|
||||
|
||||
- Governance Artifact Lifecycle & Retention runtime.
|
||||
- Management Report PDF staging/Dokploy renderer validation and release hardening.
|
||||
- Customer portal or external/customer-consumption boundary contract.
|
||||
- Customer-facing localization copy QA once gate copy is stable.
|
||||
- Structural report-delivery packaging if route gating exposes a larger artifact-delivery design gap.
|
||||
188
specs/392-customer-output-gating-review-pack-navigation/tasks.md
Normal file
188
specs/392-customer-output-gating-review-pack-navigation/tasks.md
Normal file
@ -0,0 +1,188 @@
|
||||
# Tasks: Spec 392 - Customer Output Gating & Review Pack Navigation v1
|
||||
|
||||
**Input**: `specs/392-customer-output-gating-review-pack-navigation/spec.md` and `plan.md`
|
||||
**Prerequisites**: Spec artifacts prepared; implementation must start from repo-truth verification and must not modify completed context specs.
|
||||
**Tests**: Required. This is a customer-output trust boundary and route-gating change with Unit, Feature/HTTP, Filament/Livewire, and bounded Browser proof.
|
||||
|
||||
## Test Governance Checklist
|
||||
|
||||
- [x] Lane assignment is named and narrow: Unit for gate derivation, Feature/HTTP for direct routes, Filament/Livewire for action labels/state, Browser for final trust-path proof.
|
||||
- [x] New or changed tests stay in the smallest honest family; Browser coverage is one explicit Spec 392 smoke file.
|
||||
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default.
|
||||
- [x] Planned validation commands cover the change without pulling unrelated heavy-governance cost.
|
||||
- [x] The declared surface profile is customer-safe strategic review surface + artifact download route.
|
||||
- [x] Any unreachable safe/unsafe state is documented in the active spec package rather than faked.
|
||||
|
||||
## Phase 1: Repo Truth And Action Inventory
|
||||
|
||||
**Purpose**: Confirm all output paths before changing behavior and prevent reopening completed specs.
|
||||
|
||||
- [x] T001 Re-read `specs/392-customer-output-gating-review-pack-navigation/spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md`.
|
||||
- [x] T002 Re-read completed context specs as read-only inputs only: `specs/342-customer-review-workspace-final-consumption-productization`, `specs/347-review-pack-output-contract-readiness-semantics`, `specs/351-review-output-resolve-actions-v1`, and `specs/372-customer-auditor-surface-safety-pass`.
|
||||
- [x] T003 Re-read `specs/browser-productization-bug-audit/browser-bug-report.md` and confirm BUG-007 remains in scope as a CTA truthfulness defect.
|
||||
- [x] T004 Confirm current branch and dirty state with `git status --short --branch` and `git log -1 --oneline`.
|
||||
- [x] T005 Inventory every customer-output open/download action or route in `apps/platform/app`, `apps/platform/routes`, `apps/platform/resources`, and `apps/platform/lang` using search terms from the spec.
|
||||
- [x] T006 Record the action inventory in the implementation notes or PR close-out: label, page/route, destination, current visibility condition, current authorization, customer-facing/internal classification, and whether it streams/renders/links output.
|
||||
- [x] T007 Inspect existing readiness/disclosure sources before adding a new helper:
|
||||
- `apps/platform/app/Support/ReviewPacks/ReviewPackOutputReadiness.php`
|
||||
- `apps/platform/app/Support/ReviewPacks/ReviewPackOutputResolutionGuidance.php`
|
||||
- `apps/platform/app/Support/ReviewPacks/ReportDisclosurePolicy.php`
|
||||
- [x] T008 Inspect current route/controller behavior:
|
||||
- `apps/platform/app/Http/Controllers/ReviewPackDownloadController.php`
|
||||
- `apps/platform/app/Http/Controllers/ReviewPackRenderedReportController.php`
|
||||
- `apps/platform/app/Http/Controllers/ManagementReportPdfDownloadController.php`
|
||||
- `apps/platform/routes/web.php`
|
||||
- [x] T009 Inspect current UI/action behavior:
|
||||
- `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`
|
||||
- `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`
|
||||
- `apps/platform/app/Filament/Resources/ReviewPackResource.php`
|
||||
- `apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`
|
||||
- `apps/platform/app/Filament/Resources/EnvironmentReviewResource.php`
|
||||
- `apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ViewEnvironmentReview.php`
|
||||
- `apps/platform/app/Support/EnvironmentDashboard/EnvironmentDashboardSummaryBuilder.php`
|
||||
- `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php`
|
||||
- [x] T010 Confirm no migration, package, env var, queue, scheduler, storage topology, Graph scope, panel-provider, route family, report renderer, or customer portal change is required; stop and update spec/plan if false.
|
||||
- [x] T011 Confirm Filament v5 / Livewire v4.0+ compliance and no Filament v3/v4 or Livewire v3 APIs.
|
||||
- [x] T012 Confirm panel provider registration remains `apps/platform/bootstrap/providers.php`.
|
||||
- [x] T013 Confirm no global-search participation is added or changed.
|
||||
|
||||
## Phase 2: Gate Contract And Unit Tests
|
||||
|
||||
**Purpose**: Define customer-output safety once and prove its state mapping before route/UI changes.
|
||||
|
||||
- [x] T014 Add or update focused Unit tests for the customer-output gate under `apps/platform/tests/Unit/Support/ReviewPacks/` or the narrowest existing support test family.
|
||||
- [x] T015 Test `Ready` when review output is published, customer-safe, current, artifact-backed, and authorized.
|
||||
- [x] T016 Test `Blocked` when PII/unredacted sensitive data or explicit internal-only output blocks customer-facing output.
|
||||
- [x] T017 Test `Needs attention` or `Blocked` for limitations-bearing, incomplete evidence, required-section gaps, disclosure missing, or publish-blocker states according to existing severity.
|
||||
- [x] T018 Test `Not configured` for missing output artifact or unavailable customer workspace.
|
||||
- [x] T019 Test `Expired` for expired, stale, revoked, superseded, or archived output where repo-backed fields exist.
|
||||
- [x] T020 Test `Unknown` when safety cannot be determined from current repo truth.
|
||||
- [x] T021 Test permission denial blocks customer output even if output is otherwise safe.
|
||||
- [x] T022 Test permission grant does not override unsafe customer output.
|
||||
- [x] T023 Implement the narrowest customer-output gate/result adapter only if existing helpers cannot serve route and UI needs directly.
|
||||
- [x] T024 Ensure any new gate/result adapter reuses existing readiness/disclosure helpers and remains non-persistent.
|
||||
- [x] T025 Ensure gate evaluation is DB/storage-backed only and performs no Graph or remote calls.
|
||||
|
||||
## Phase 3: Route Enforcement
|
||||
|
||||
**Purpose**: Prevent direct-route bypass before relying on UI state.
|
||||
|
||||
- [x] T026 Add Feature/HTTP tests under `apps/platform/tests/Feature/ReviewPack/` or the narrowest existing family proving safe `ReviewPackDownloadController` downloads succeed and audit.
|
||||
- [x] T027 Add Feature/HTTP tests proving PII, limitations, missing, failed, expired, superseded, archived, and unknown customer-output states do not stream a Review Pack file through direct URL access.
|
||||
- [x] T028 Update `apps/platform/app/Http/Controllers/ReviewPackDownloadController.php` to enforce the customer-output gate before file streaming.
|
||||
- [x] T029 Add or update Feature/HTTP tests for `ReviewPackRenderedReportController` proving blocked output does not render customer-facing download/open actions or customer-safe report claims.
|
||||
- [x] T030 Update `apps/platform/app/Http/Controllers/ReviewPackRenderedReportController.php` to consume the same gate for customer-facing rendered report output and action labels.
|
||||
- [x] T031 Inspect `apps/platform/app/Http/Controllers/ManagementReportPdfDownloadController.php`; classify it as customer-facing or internal-only using the spec/plan criteria, record the decision in implementation notes or PR close-out, and if customer-facing add equivalent gate tests and enforcement.
|
||||
- [x] T032 Add or update route regression tests proving wrong workspace/environment access remains 404 deny-as-not-found and entitled-member missing-capability access remains 403 for `ReviewPackDownloadController`, `ReviewPackRenderedReportController`, and `ManagementReportPdfDownloadController` if customer-facing.
|
||||
- [x] T033 Preserve existing workspace membership 404, managed-environment entitlement checks, capability 403, artifact status, expiry, file existence, and audit behavior while adding gate checks.
|
||||
- [x] T034 Ensure blocked direct access returns 403, 404 deny-as-not-found, or safe admin redirect without file content and without sensitive details.
|
||||
- [x] T035 Add or update audit assertions for successful customer-output downloads and internal-preview downloads if internal preview is implemented.
|
||||
|
||||
## Phase 4: Customer Workspace And Review Pack Action Labels
|
||||
|
||||
**Purpose**: Make every customer-output CTA truthful by destination and safety state.
|
||||
|
||||
- [x] T036 Add or update Filament/Livewire tests proving `Open customer workspace` opens only `CustomerReviewWorkspace` and never Review Pack detail.
|
||||
- [x] T037 Add or update Filament/Livewire tests proving Review Pack detail links are labelled `Open review pack`.
|
||||
- [x] T038 Update `apps/platform/app/Support/EnvironmentDashboard/EnvironmentDashboardSummaryBuilder.php` so customer-workspace CTA labels match actual destinations.
|
||||
- [x] T039 Update `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php` only if current links can still label internal artifact/detail routes as customer workspace.
|
||||
- [x] T040 Update `apps/platform/app/Filament/Resources/EnvironmentReviewResource.php` and related view page only where customer workspace / review pack / report action labels are misleading.
|
||||
- [x] T041 Update `apps/platform/app/Filament/Resources/ReviewPackResource.php` and `apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php` so customer output, internal preview, rendered report, and review pack labels match destinations and gate state.
|
||||
- [x] T042 Update `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` and `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php` so the primary customer-output action appears only when the gate permits it.
|
||||
- [x] T043 Ensure affected pages show one top-level customer output state by default: `Ready`, `Needs attention`, `Blocked`, `Not configured`, `Expired`, or `Unknown`.
|
||||
- [x] T044 Ensure blocked states show one concise reason and avoid implementation terms such as artifact payload, detector, source key, operation failure, fingerprint, or raw policy internals in default copy.
|
||||
|
||||
## Phase 5: Internal Preview Separation
|
||||
|
||||
**Purpose**: Keep operator-only preview useful without presenting it as customer output.
|
||||
|
||||
- [x] T045 Add or update tests proving deprecated limited-download copy is absent from customer-facing pages and reports.
|
||||
- [x] T046 If a limitations-bearing artifact remains accessible, relabel it as `Download internal preview` in `apps/platform/lang/en/localization.php` and `apps/platform/lang/de/localization.php`.
|
||||
- [x] T047 Gate internal preview behind internal/operator permission and ensure it is secondary, not the primary customer-facing action.
|
||||
- [x] T048 Add tests proving customer/read-only users cannot see or access internal preview.
|
||||
- [x] T049 Add tests proving internal preview direct route/action cannot be used as customer-output bypass.
|
||||
- [x] T050 Ensure customer-output labels are not used for limitations-bearing or internal-only artifacts.
|
||||
|
||||
## Phase 6: Customer-Safe Disclosure And Proof Demotion
|
||||
|
||||
**Purpose**: Preserve audit depth while preventing default customer-facing proof leakage.
|
||||
|
||||
- [x] T051 Add or update Feature/Browser assertions that customer-facing surfaces do not show raw IDs, source keys, fingerprints, raw payloads, OperationRun internals, baseline internals, detector names, or report-generation metadata by default.
|
||||
- [x] T052 Keep technical proof reachable only through existing internal/audit/detail actions where authorized.
|
||||
- [x] T053 Ensure proof/action labels use `View audit trail`, `View technical details`, or similar internal labels, not customer-output labels.
|
||||
- [x] T054 Remove duplicated visible readiness/status badges where they compete with the canonical customer-output state on affected surfaces.
|
||||
|
||||
## Phase 7: Localization, UI Coverage, And Regression Guards
|
||||
|
||||
**Purpose**: Align copy and active feature artifacts without broad docs churn.
|
||||
|
||||
- [x] T055 Update only required localization keys in `apps/platform/lang/en/localization.php` and `apps/platform/lang/de/localization.php`.
|
||||
- [x] T056 Update targeted tests that assert current copy so they assert semantics rather than stale unsafe labels.
|
||||
- [x] T057 Decide after runtime diff whether existing page reports under `docs/ui-ux-enterprise-audit/page-reports/` require updates; update only materially affected page reports.
|
||||
- [x] T058 Document no-count-change/no-archetype-change if `route-inventory.md` and `design-coverage-matrix.md` remain unchanged.
|
||||
- [x] T059 Do not create general documentation files outside required Spec Kit/UI coverage artifacts.
|
||||
|
||||
## Phase 8: Browser Smoke And Direct URL Proof
|
||||
|
||||
**Purpose**: Prove the user-visible trust path after targeted tests pass.
|
||||
|
||||
- [x] T060 Add `apps/platform/tests/Browser/Spec392CustomerOutputGatingSmokeTest.php` using existing review-output fixture helpers where practical.
|
||||
- [x] T061 Browser state: safe output; assert `Open customer workspace` opens actual Customer Review Workspace and `Download customer output` succeeds only in ready state.
|
||||
- [x] T062 Browser state: unsafe/limited output; assert customer output is blocked, reason is visible, and no customer-facing deprecated limited-download copy appears.
|
||||
- [x] T063 Browser state: internal preview if present; assert label is internal, secondary, and hidden from customer/read-only mode.
|
||||
- [x] T064 Browser state: dashboard CTA; assert a link labelled `Open customer workspace` does not land on Review Pack detail.
|
||||
- [x] T065 Direct URL proof: assert blocked output direct route returns no file content.
|
||||
- [x] T066 Browser harness did not capture Spec 392 screenshots; no screenshot artifacts were added.
|
||||
|
||||
## Phase 9: Validation And Close-Out
|
||||
|
||||
**Purpose**: Prove the implementation and record deployment impact clearly.
|
||||
|
||||
- [x] T067 Attempt Sail validation; Docker was not running, so run the equivalent local Pest Spec 392 gate/route/architecture validation.
|
||||
- [x] T068 Run targeted existing regressions based on touched surfaces, at minimum `CustomerReviewWorkspace`, `ReviewPack`, and `StoredReport` filters if those surfaces changed.
|
||||
- [x] T069 Run local Pest Browser validation for `tests/Browser/Spec392CustomerOutputGatingSmokeTest.php` because Docker/Sail was unavailable.
|
||||
- [x] T070 Run `cd apps/platform && php vendor/bin/pint --dirty`.
|
||||
- [x] T071 Run `git diff --check`.
|
||||
- [x] T072 Confirm no migrations, seeders, packages, env vars, queues, scheduler, storage topology, Graph contracts/calls, panel providers, new route family, customer portal, report renderer, or legacy compatibility path were added.
|
||||
- [x] T073 Confirm final Livewire v4 compliance, provider registration location, global-search posture, destructive/high-impact action status, asset strategy, tests, deployment impact, and Guardrail / Exception / Smoke Coverage in the implementation close-out response.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Phase 1 must complete before runtime implementation.
|
||||
- Phase 2 gate tests should land before or alongside route enforcement.
|
||||
- Phase 3 route enforcement must complete before UI-only assumptions are considered safe.
|
||||
- Phase 4 and Phase 5 can proceed in parallel after the gate decision shape is stable.
|
||||
- Phase 8 runs after targeted tests and route enforcement are implemented.
|
||||
- Phase 9 closes the feature.
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
- T007, T008, and T009 can be split by repo surface during inspection.
|
||||
- T026-T031 can be split by route/controller after the gate API is stable.
|
||||
- T038-T042 can be split by UI surface if no shared helper conflict exists.
|
||||
- T055-T058 can run after labels and UI diff are known.
|
||||
|
||||
## Non-Goals / Stop Conditions
|
||||
|
||||
- Stop if implementation requires a new persisted readiness table, new customer portal, new report renderer, new review publication workflow, new route family, or broad Customer Review Workspace redesign.
|
||||
- Stop if management-report PDF production enablement becomes necessary; that belongs to Spec 379 follow-through.
|
||||
- Stop if internal preview requires a new capability model not expressible by current capabilities; update spec/plan first.
|
||||
- Stop if route gating cannot be implemented without changing artifact lifecycle/retention semantics; promote a separate lifecycle spec.
|
||||
- Do not rewrite, normalize, uncheck, or remove implementation history from completed Specs 342, 347, 351, 372, or browser audit artifacts.
|
||||
|
||||
## Required Final Report Content For Later Implementation
|
||||
|
||||
When implementation later completes, report:
|
||||
|
||||
- Changed behavior.
|
||||
- Customer-output gate states and blocking reasons.
|
||||
- Route enforcement results for safe and unsafe output.
|
||||
- CTA label/destination corrections.
|
||||
- Internal preview behavior, if present.
|
||||
- RBAC/context behavior.
|
||||
- Files changed.
|
||||
- Tests run and results.
|
||||
- Browser smoke and screenshot path.
|
||||
- Known gaps and follow-up specs.
|
||||
- Full suite run/not run.
|
||||
- Explicit no migrations/packages/env/queues/scheduler/storage/deployment assets/Graph/panel-provider/destructive-action/backcompat/new-customer-portal statement.
|
||||
Loading…
Reference in New Issue
Block a user