feat: productize restore wizard preview safety gates and process flow #399
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -5,6 +5,7 @@
|
||||
use App\Filament\Resources\EntraGroupResource;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
@ -18,14 +19,39 @@ class EntraGroupCachePickerTable extends TableComponent
|
||||
{
|
||||
public string $sourceGroupId;
|
||||
|
||||
public function mount(string $sourceGroupId): void
|
||||
public ?string $sourceGroupDisplayName = null;
|
||||
|
||||
public ?int $tenantId = null;
|
||||
|
||||
public bool $hasCachedGroups = false;
|
||||
|
||||
public ?string $groupSyncUrl = null;
|
||||
|
||||
public ?string $groupSyncOperationsUrl = null;
|
||||
|
||||
public function mount(string $sourceGroupId, ?string $sourceGroupDisplayName = null, ?int $tenantId = null): void
|
||||
{
|
||||
$this->sourceGroupId = $sourceGroupId;
|
||||
$this->sourceGroupDisplayName = filled($sourceGroupDisplayName) ? $sourceGroupDisplayName : null;
|
||||
|
||||
$this->tenantId = is_int($tenantId) && $tenantId > 0 ? $tenantId : null;
|
||||
|
||||
$tenant = $this->resolveTenantContext();
|
||||
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
$this->tenantId = (int) $tenant->getKey();
|
||||
|
||||
$this->hasCachedGroups = EntraGroup::query()
|
||||
->where('managed_environment_id', $tenant->getKey())
|
||||
->exists();
|
||||
$this->groupSyncUrl = EntraGroupResource::getUrl('index', tenant: $tenant);
|
||||
$this->groupSyncOperationsUrl = OperationRunLinks::index($tenant, operationType: 'directory.groups.sync');
|
||||
}
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
$tenantId = ManagedEnvironment::current()?->getKey();
|
||||
$tenantId = $this->tenantId ?? $this->resolveTenantContext()?->getKey();
|
||||
|
||||
$query = EntraGroup::query();
|
||||
|
||||
@ -143,18 +169,9 @@ public function table(Table $table): Table
|
||||
$this->dispatch('entra-group-cache-picked', sourceGroupId: $this->sourceGroupId, entraId: (string) $record->entra_id);
|
||||
}),
|
||||
])
|
||||
->emptyStateHeading('No cached groups found')
|
||||
->emptyStateDescription('Run “Sync Groups” first, then come back here.')
|
||||
->emptyStateActions([
|
||||
Action::make('open_groups')
|
||||
->label('Directory Groups')
|
||||
->icon('heroicon-o-user-group')
|
||||
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: ManagedEnvironment::current())),
|
||||
Action::make('open_sync_runs')
|
||||
->label('Operations')
|
||||
->icon('heroicon-o-clock')
|
||||
->url(fn (): string => OperationRunLinks::index(ManagedEnvironment::current())),
|
||||
]);
|
||||
->emptyStateHeading('No cached directory groups match your search')
|
||||
->emptyStateDescription('Try a broader search, or sync directory groups if the cache is incomplete for this environment.')
|
||||
->emptyStateActions([]);
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
@ -200,4 +217,25 @@ private function groupTypeColor(string $type): string
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveTenantContext(): ?ManagedEnvironment
|
||||
{
|
||||
if ($this->tenantId !== null) {
|
||||
$tenant = ManagedEnvironment::query()->find($this->tenantId);
|
||||
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
$tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request());
|
||||
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
$tenant = ManagedEnvironment::current();
|
||||
|
||||
return $tenant instanceof ManagedEnvironment ? $tenant : null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,8 +12,10 @@ public function __construct(public bool $allowSkip = true) {}
|
||||
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
$message = 'Enter a valid group object ID (GUID), or use Skip assignment.';
|
||||
|
||||
if (! is_string($value)) {
|
||||
$fail('Please enter SKIP or a valid UUID.');
|
||||
$fail($message);
|
||||
|
||||
return;
|
||||
}
|
||||
@ -21,7 +23,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '') {
|
||||
$fail('Please enter SKIP or a valid UUID.');
|
||||
$fail($message);
|
||||
|
||||
return;
|
||||
}
|
||||
@ -31,7 +33,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
}
|
||||
|
||||
if (! Str::isUuid($value)) {
|
||||
$fail('Please enter SKIP or a valid UUID.');
|
||||
$fail($message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -112,7 +112,7 @@ public function lookupMany(ManagedEnvironment $tenant, array $groupIds): array
|
||||
|
||||
public static function formatLabel(?string $displayName, string $id): string
|
||||
{
|
||||
$name = filled($displayName) ? $displayName : 'Unresolved';
|
||||
$name = filled($displayName) ? $displayName : 'Unknown group';
|
||||
|
||||
return sprintf('%s (%s)', trim($name), self::shortToken($id));
|
||||
}
|
||||
|
||||
@ -4,9 +4,9 @@
|
||||
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Services\Graph\GroupResolver;
|
||||
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
||||
use Carbon\CarbonImmutable;
|
||||
@ -365,8 +365,8 @@ private function checkMetadataOnlySnapshots(Collection $policyItems): ?array
|
||||
|
||||
$severity = $hasRestoreEnabled ? 'blocking' : 'warning';
|
||||
$message = $hasRestoreEnabled
|
||||
? 'Some selected items were captured as metadata-only. Restore cannot execute until Graph works again.'
|
||||
: 'Some selected items were captured as metadata-only. Execution is preview-only, but payload completeness is limited.';
|
||||
? 'Some selected items were captured as metadata-only. Restore execution is blocked until provider connectivity is restored.'
|
||||
: 'Some selected items were captured as metadata-only. Execution remains preview-only, and payload completeness is limited.';
|
||||
|
||||
return [
|
||||
'code' => 'metadata_only',
|
||||
|
||||
@ -474,6 +474,6 @@ private function defaultSetCompactSummary(int $totalItems): string
|
||||
|
||||
private function positiveClaimBoundary(): string
|
||||
{
|
||||
return 'Input quality signals do not prove safe restore, restore readiness, or tenant-wide recoverability.';
|
||||
return 'Input quality signals do not prove that execution is safe or that recovery is verified.';
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,9 +18,9 @@ public function spec(mixed $value): BadgeSpec
|
||||
'blocking' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-x-circle'),
|
||||
'warning' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-exclamation-triangle'),
|
||||
'safe' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-check-circle'),
|
||||
'current' => new BadgeSpec('Current checks', 'success', 'heroicon-m-check-circle', 'success'),
|
||||
'current' => new BadgeSpec('Latest check result', 'info', 'heroicon-m-clock', 'info'),
|
||||
'invalidated' => new BadgeSpec('Invalidated', 'warning', 'heroicon-m-arrow-path-rounded-square', 'warning'),
|
||||
'stale' => new BadgeSpec('Legacy stale', 'gray', 'heroicon-m-clock', 'gray'),
|
||||
'stale' => new BadgeSpec('Stale evidence', 'gray', 'heroicon-m-clock', 'gray'),
|
||||
'not_run' => new BadgeSpec('Not run', 'gray', 'heroicon-m-eye-slash', 'gray'),
|
||||
default => BadgeSpec::unknown(),
|
||||
} ?? BadgeSpec::unknown();
|
||||
|
||||
@ -23,7 +23,7 @@ public function spec(mixed $value): BadgeSpec
|
||||
'failed' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-x-circle'),
|
||||
'current' => new BadgeSpec('Current basis', 'success', 'heroicon-m-check-circle', 'success'),
|
||||
'invalidated' => new BadgeSpec('Invalidated', 'warning', 'heroicon-m-arrow-path-rounded-square', 'warning'),
|
||||
'stale' => new BadgeSpec('Legacy stale', 'gray', 'heroicon-m-clock', 'gray'),
|
||||
'stale' => new BadgeSpec('Stale evidence', 'gray', 'heroicon-m-clock', 'gray'),
|
||||
'not_generated' => new BadgeSpec('Not generated', 'gray', 'heroicon-m-eye-slash', 'gray'),
|
||||
default => BadgeSpec::unknown(),
|
||||
} ?? BadgeSpec::unknown();
|
||||
|
||||
@ -22,10 +22,11 @@ public static function safetyStateLabel(?string $state): string
|
||||
public static function primaryNextAction(?string $action): string
|
||||
{
|
||||
return match ($action) {
|
||||
'resolve_blockers' => 'Resolve the technical blockers before real execution.',
|
||||
'resolve_blockers' => 'Review prerequisites before execution.',
|
||||
'generate_preview' => 'Generate a preview for the current scope.',
|
||||
'regenerate_preview' => 'Regenerate the preview for the current scope.',
|
||||
'rerun_checks' => 'Run the safety checks again for the current scope.',
|
||||
'review_and_confirm' => 'Review the preview and complete confirmation before execution can be queued.',
|
||||
'review_warnings' => 'Review the warnings before real execution.',
|
||||
'execute' => 'Queue the real restore execution.',
|
||||
'review_preview' => 'Review the preview evidence before claiming recovery or queueing execution.',
|
||||
@ -58,9 +59,9 @@ public static function recoveryBoundary(?string $boundary): string
|
||||
{
|
||||
return match ($boundary) {
|
||||
'preview_only_no_execution_proven' => 'No execution was performed from this record.',
|
||||
'execution_failed_no_recovery_claim' => 'ManagedEnvironment recovery is not proven.',
|
||||
'run_completed_not_recovery_proven' => 'ManagedEnvironment-wide recovery is not proven.',
|
||||
default => 'ManagedEnvironment-wide recovery is not proven.',
|
||||
'execution_failed_no_recovery_claim' => 'Target environment recovery is not proven.',
|
||||
'run_completed_not_recovery_proven' => 'Target environment recovery is not proven.',
|
||||
default => 'Target environment recovery is not proven.',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -6,13 +6,14 @@
|
||||
|
||||
use App\Contracts\Hardening\WriteGateInterface;
|
||||
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Providers\ProviderConnectionResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
|
||||
final readonly class RestoreSafetyResolver
|
||||
{
|
||||
@ -22,6 +23,7 @@ public function __construct(
|
||||
private CapabilityResolver $capabilityResolver,
|
||||
private WriteGateInterface $writeGate,
|
||||
private TenantBackupHealthResolver $backupHealthResolver,
|
||||
private ProviderConnectionResolver $providerConnections,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -274,6 +276,14 @@ public function executionReadiness(ManagedEnvironment $tenant, User $user, array
|
||||
$blockingReasons[] = 'missing_capability';
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$providerResolution = $this->providerConnections->resolveDefault($tenant, 'microsoft');
|
||||
|
||||
if (! $providerResolution->resolved) {
|
||||
$blockingReasons[] = $providerResolution->effectiveReasonCode();
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
try {
|
||||
$this->writeGate->evaluate($tenant, 'restore.execute');
|
||||
@ -294,8 +304,8 @@ public function executionReadiness(ManagedEnvironment $tenant, User $user, array
|
||||
$allowed = $blockingReasons === [];
|
||||
|
||||
$displaySummary = $allowed
|
||||
? 'The platform can start a restore for this tenant once the operator chooses to proceed.'
|
||||
: 'Technical startability is blocked until capability, write-gate, or hard-blocker issues are resolved.';
|
||||
? 'Restore execution can start for this environment once the operator chooses to proceed.'
|
||||
: 'Provider readiness or restore prerequisites currently prevent real execution.';
|
||||
|
||||
return new ExecutionReadinessState(
|
||||
allowed: $allowed,
|
||||
@ -324,7 +334,7 @@ public function safetyAssessment(ManagedEnvironment $tenant, User $user, array $
|
||||
positiveClaimSuppressed: true,
|
||||
primaryIssueCode: $executionReadiness->blockingReasons[0] ?? 'execution_blocked',
|
||||
primaryNextAction: 'resolve_blockers',
|
||||
summary: 'Real execution is blocked until the technical prerequisites are healthy again.',
|
||||
summary: 'Restore execution is blocked until required prerequisites are healthy again.',
|
||||
);
|
||||
}
|
||||
|
||||
@ -337,7 +347,7 @@ public function safetyAssessment(ManagedEnvironment $tenant, User $user, array $
|
||||
positiveClaimSuppressed: true,
|
||||
primaryIssueCode: $previewIntegrity->state,
|
||||
primaryNextAction: 'regenerate_preview',
|
||||
summary: 'Real execution is technically possible, but the preview basis is not current enough to support a calm go signal.',
|
||||
summary: 'Execution could start, but the preview basis is not current enough to support a calm go signal.',
|
||||
);
|
||||
}
|
||||
|
||||
@ -350,7 +360,7 @@ public function safetyAssessment(ManagedEnvironment $tenant, User $user, array $
|
||||
positiveClaimSuppressed: true,
|
||||
primaryIssueCode: $checksIntegrity->state,
|
||||
primaryNextAction: 'rerun_checks',
|
||||
summary: 'Real execution is technically possible, but the checks basis is not current enough to support a calm go signal.',
|
||||
summary: 'Execution could start, but the checks basis is not current enough to support a calm go signal.',
|
||||
);
|
||||
}
|
||||
|
||||
@ -428,7 +438,7 @@ public function resultAttentionForRun(RestoreRun $restoreRun): RestoreResultAtte
|
||||
state: RestoreResultAttention::STATE_NOT_EXECUTED,
|
||||
followUpRequired: false,
|
||||
primaryCauseFamily: 'none',
|
||||
summary: 'This record proves preview truth, not tenant recovery.',
|
||||
summary: 'This record proves preview truth, not environment recovery.',
|
||||
primaryNextAction: 'review_preview',
|
||||
recoveryClaimBoundary: 'preview_only_no_execution_proven',
|
||||
tone: 'gray',
|
||||
@ -475,7 +485,7 @@ public function resultAttentionForRun(RestoreRun $restoreRun): RestoreResultAtte
|
||||
state: RestoreResultAttention::STATE_COMPLETED,
|
||||
followUpRequired: false,
|
||||
primaryCauseFamily: 'none',
|
||||
summary: 'The restore completed without visible follow-up, but this still does not prove tenant-wide recovery.',
|
||||
summary: 'The restore completed without visible follow-up, but this still does not prove environment-wide recovery.',
|
||||
primaryNextAction: 'review_result',
|
||||
recoveryClaimBoundary: 'run_completed_not_recovery_proven',
|
||||
tone: 'success',
|
||||
@ -500,6 +510,7 @@ public function dashboardRecoveryEvidence(ManagedEnvironment $tenant): array
|
||||
{
|
||||
$backupHealth = $this->backupHealthResolver->assess($tenant);
|
||||
$relevantRestoreHistory = $this->latestRelevantRestoreHistory($tenant);
|
||||
|
||||
return $this->dashboardRecoveryEvidencePayload(
|
||||
backupHealth: $backupHealth,
|
||||
relevantRun: $relevantRestoreHistory['run'],
|
||||
|
||||
@ -0,0 +1,149 @@
|
||||
@php
|
||||
$processFlow = is_array($processFlow ?? null) ? $processFlow : [];
|
||||
$steps = is_array($processFlow['steps'] ?? null) ? $processFlow['steps'] : [];
|
||||
$compact = (bool) ($processFlow['compact'] ?? false);
|
||||
|
||||
$badgeTone = static function (string $status): string {
|
||||
return match ($status) {
|
||||
'complete' => 'success',
|
||||
'required' => 'warning',
|
||||
'blocked' => 'danger',
|
||||
default => 'gray',
|
||||
};
|
||||
};
|
||||
|
||||
$badgeLabel = static function (string $status): string {
|
||||
return match ($status) {
|
||||
'complete' => 'Complete',
|
||||
'required' => 'Required',
|
||||
'blocked' => 'Blocked',
|
||||
default => 'Unavailable',
|
||||
};
|
||||
};
|
||||
@endphp
|
||||
|
||||
@if ($compact)
|
||||
<div data-testid="restore-run-process-flow-compact" x-data="{ safetyGatesOpen: false }">
|
||||
<x-filament::section heading="Restore safety status">
|
||||
<div class="space-y-3 text-sm text-gray-700 dark:text-gray-200">
|
||||
<div class="grid gap-1">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $processFlow['gatesComplete'] ?? 0 }}/{{ $processFlow['gatesTotal'] ?? count($steps) }} gates complete
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">Next gate:</span>
|
||||
<span class="text-gray-600 dark:text-gray-300">{{ $processFlow['nextGate'] ?? 'Unavailable' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">Execution:</span>
|
||||
<span class="text-gray-600 dark:text-gray-300">{{ $processFlow['executionLabel'] ?? 'Unavailable' }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ $processFlow['executionSummary'] ?? 'Execution remains unavailable until required safety gates are complete.' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
x-on:click="safetyGatesOpen = ! safetyGatesOpen"
|
||||
x-text="safetyGatesOpen ? 'Hide safety gates' : 'View safety gates'"
|
||||
>
|
||||
View safety gates
|
||||
</button>
|
||||
|
||||
<template x-if="safetyGatesOpen">
|
||||
<div class="space-y-2" data-testid="restore-run-process-flow-compact-expanded">
|
||||
@foreach ($steps as $step)
|
||||
@php
|
||||
$status = (string) ($step['status'] ?? 'unavailable');
|
||||
@endphp
|
||||
<div
|
||||
data-testid="restore-run-process-flow-step"
|
||||
data-step-label="{{ $step['label'] ?? 'Gate' }}"
|
||||
data-step-status="{{ $status }}"
|
||||
class="rounded-lg border border-gray-200 bg-white px-3 py-3 shadow-sm dark:border-white/10 dark:bg-white/5"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex h-7 w-7 items-center justify-center rounded-full border border-gray-200 text-xs font-semibold text-gray-700 dark:border-white/10 dark:text-gray-200">
|
||||
{{ $step['step'] ?? '•' }}
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ $step['label'] ?? 'Gate' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ $step['summary'] ?? '' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-filament::badge :color="$badgeTone($status)" size="sm">
|
||||
{{ $badgeLabel($status) }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
@else
|
||||
<template
|
||||
x-if="typeof isStepAccessible === 'function' ? String(step).includes('select-backup-set') : true"
|
||||
>
|
||||
<div data-testid="restore-run-process-flow-full">
|
||||
<x-filament::section heading="{{ $processFlow['title'] ?? 'Restore safety gates' }}">
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
Restore safety stays visible from the first step. This flow shows what is complete, what is still blocked, and what remains unavailable before execution.
|
||||
</p>
|
||||
|
||||
<ol class="space-y-3" aria-label="Restore safety gates">
|
||||
@foreach ($steps as $step)
|
||||
@php
|
||||
$status = (string) ($step['status'] ?? 'unavailable');
|
||||
$cardClasses = match ($status) {
|
||||
'complete' => 'border-success-200 bg-success-50/60 dark:border-success-700 dark:bg-success-950/30',
|
||||
'required' => 'border-warning-200 bg-warning-50/60 dark:border-warning-700 dark:bg-warning-950/30',
|
||||
'blocked' => 'border-danger-200 bg-danger-50/60 dark:border-danger-700 dark:bg-danger-950/30',
|
||||
default => 'border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-950/50',
|
||||
};
|
||||
@endphp
|
||||
|
||||
<li
|
||||
data-testid="restore-run-process-flow-step"
|
||||
data-step-label="{{ $step['label'] ?? 'Gate' }}"
|
||||
data-step-status="{{ $status }}"
|
||||
class="rounded-lg border px-4 py-4 {{ $cardClasses }}"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-gray-200 bg-white text-xs font-semibold text-gray-600 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300">
|
||||
{{ $step['step'] ?? '•' }}
|
||||
</span>
|
||||
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ $step['label'] ?? 'Gate' }}
|
||||
</div>
|
||||
<x-filament::badge :color="$badgeTone($status)" size="sm">
|
||||
{{ $badgeLabel($status) }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
{{ $step['summary'] ?? '' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ol>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
</template>
|
||||
@endif
|
||||
@ -0,0 +1,56 @@
|
||||
@php
|
||||
$proofAside = is_array($proofAside ?? null) ? $proofAside : [];
|
||||
$items = is_array($proofAside['items'] ?? null) ? $proofAside['items'] : [];
|
||||
$diagnosticsDisclosure = is_array($diagnosticsDisclosure ?? null) ? $diagnosticsDisclosure : [];
|
||||
|
||||
$badgeClasses = static function (string $tone): string {
|
||||
return match ($tone) {
|
||||
'success' => 'border-success-200 bg-success-50 text-success-700 dark:border-success-700 dark:bg-success-950/30 dark:text-success-300',
|
||||
'warning' => 'border-warning-200 bg-warning-50 text-warning-700 dark:border-warning-700 dark:bg-warning-950/30 dark:text-warning-300',
|
||||
'danger' => 'border-danger-200 bg-danger-50 text-danger-700 dark:border-danger-700 dark:bg-danger-950/30 dark:text-danger-300',
|
||||
default => 'border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300',
|
||||
};
|
||||
};
|
||||
@endphp
|
||||
|
||||
<aside data-testid="restore-run-proof-panel" class="min-w-0 space-y-3">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-base font-semibold text-gray-950 dark:text-white">
|
||||
{{ $proofAside['title'] ?? 'Restore Proof' }}
|
||||
</h2>
|
||||
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
Proof stays explicit throughout the wizard. Execution proof and post-run evidence remain unavailable until a confirmed run starts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@foreach ($items as $item)
|
||||
<div
|
||||
data-testid="restore-run-proof-item"
|
||||
data-proof-label="{{ $item['label'] ?? 'Proof item' }}"
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/80"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ $item['label'] ?? 'Proof item' }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold {{ $badgeClasses((string) ($item['tone'] ?? 'gray')) }}">
|
||||
{{ $item['value'] ?? 'Unavailable' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
{{ $item['description'] ?? '' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
<details data-testid="restore-run-diagnostics-disclosure" class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ $diagnosticsDisclosure['label'] ?? 'Diagnostics - Collapsed' }}
|
||||
</summary>
|
||||
<p class="mt-3 text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
{{ $diagnosticsDisclosure['summary'] ?? 'Diagnostics remain closed by default.' }}
|
||||
</p>
|
||||
</details>
|
||||
</aside>
|
||||
@ -0,0 +1,49 @@
|
||||
@php
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
$backupQualityCard = is_array($backupQualityCard ?? null) ? $backupQualityCard : [];
|
||||
$counts = is_array($backupQualityCard['counts'] ?? null) ? $backupQualityCard['counts'] : [];
|
||||
|
||||
$statusBadgeClasses = static function (bool $available): string {
|
||||
return $available
|
||||
? 'border-warning-200 bg-warning-50 text-warning-700 dark:border-warning-700 dark:bg-warning-950/30 dark:text-warning-300'
|
||||
: 'border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300';
|
||||
};
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
<x-filament::section>
|
||||
<div data-testid="restore-run-backup-quality-summary" class="space-y-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="text-base font-semibold text-gray-950 dark:text-white">
|
||||
Backup quality summary
|
||||
</h2>
|
||||
<span class="inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold {{ $statusBadgeClasses((bool) ($backupQualityCard['available'] ?? false)) }}">
|
||||
{{ $backupQualityCard['status'] ?? 'Unavailable' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
{{ $backupQualityCard['summary'] ?? 'Backup quality hints describe input strength only.' }}
|
||||
</p>
|
||||
|
||||
@if ($counts !== [])
|
||||
<dl class="grid gap-3 md:grid-cols-5">
|
||||
@foreach ($counts as $count)
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
||||
<dt class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $count['label'] ?? 'Count' }}</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">{{ $count['value'] ?? 0 }}</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
@endif
|
||||
|
||||
<div class="rounded-lg border border-warning-200 bg-warning-50 px-4 py-3 text-sm text-warning-900 dark:border-warning-700 dark:bg-warning-950/30 dark:text-warning-100">
|
||||
{{ $backupQualityCard['positiveClaimBoundary'] ?? 'Input quality signals do not prove that execution is safe or that recovery is verified.' }}
|
||||
</div>
|
||||
|
||||
<div class="text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
{{ $backupQualityCard['nextAction'] ?? 'Inspect item-level backup detail before continuing.' }}
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-dynamic-component>
|
||||
@ -1,26 +1,35 @@
|
||||
@php
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
|
||||
$results = $getState() ?? [];
|
||||
$results = is_array($results) ? $results : [];
|
||||
$validationSummary = is_array($validationSummary ?? null) ? $validationSummary : [];
|
||||
$validationSummary = $validationSummary !== []
|
||||
? $validationSummary
|
||||
: (is_array($validation_summary ?? null) ? $validation_summary : []);
|
||||
|
||||
$summary = $summary ?? [];
|
||||
$summary = is_array($summary) ? $summary : [];
|
||||
$integritySpec = $validationSummary['integritySpec'] ?? null;
|
||||
|
||||
$checksIntegrity = $checksIntegrity ?? [];
|
||||
$checksIntegrity = is_array($checksIntegrity) ? $checksIntegrity : [];
|
||||
if (! $integritySpec instanceof \App\Support\Badges\BadgeSpec) {
|
||||
$integritySpec = \App\Support\Badges\BadgeSpec::unknown();
|
||||
}
|
||||
|
||||
$executionReadiness = $executionReadiness ?? [];
|
||||
$executionReadiness = is_array($executionReadiness) ? $executionReadiness : [];
|
||||
$blocking = (int) ($validationSummary['blockingCount'] ?? 0);
|
||||
$warning = (int) ($validationSummary['warningCount'] ?? 0);
|
||||
$safe = (int) ($validationSummary['safeCount'] ?? 0);
|
||||
|
||||
$safetyAssessment = $safetyAssessment ?? [];
|
||||
$safetyAssessment = is_array($safetyAssessment) ? $safetyAssessment : [];
|
||||
$integritySummary = (string) ($validationSummary['integritySummary'] ?? 'Run checks for the current scope before real execution.');
|
||||
$nextActionLabel = (string) ($validationSummary['nextActionLabel'] ?? 'Run the safety checks again for the current scope.');
|
||||
|
||||
$blocking = (int) ($summary['blocking'] ?? ($checksIntegrity['blocking_count'] ?? 0));
|
||||
$warning = (int) ($summary['warning'] ?? ($checksIntegrity['warning_count'] ?? 0));
|
||||
$safe = (int) ($summary['safe'] ?? 0);
|
||||
$startabilitySummary = (string) ($validationSummary['executionReadinessSummary'] ?? 'Execution prerequisites are unavailable.');
|
||||
$startabilityTone = (string) ($validationSummary['executionReadinessTone'] ?? ((bool) ($validationSummary['executionAllowed'] ?? false) ? 'success' : 'warning'));
|
||||
|
||||
$ranAt = $ranAt ?? ($checksIntegrity['ran_at'] ?? null);
|
||||
$providerCredentialBlocked = (bool) ($validationSummary['providerCredentialBlocked'] ?? false);
|
||||
$providerConnectionsUrl = $validationSummary['providerConnectionsUrl'] ?? null;
|
||||
$providerConnectionsUrl = is_string($providerConnectionsUrl) && $providerConnectionsUrl !== '' ? $providerConnectionsUrl : null;
|
||||
|
||||
$invalidationReasons = is_array($validationSummary['invalidationReasons'] ?? null) ? $validationSummary['invalidationReasons'] : [];
|
||||
$groupedResults = is_array($validationSummary['groupedResults'] ?? null) ? $validationSummary['groupedResults'] : [];
|
||||
|
||||
$ranAt = $validationSummary['ranAt'] ?? null;
|
||||
$ranAtLabel = null;
|
||||
|
||||
if (is_string($ranAt) && $ranAt !== '') {
|
||||
@ -31,27 +40,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
$severitySpec = static function (?string $severity): \App\Support\Badges\BadgeSpec {
|
||||
return \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreCheckSeverity, $severity);
|
||||
};
|
||||
|
||||
$integritySpec = $severitySpec($checksIntegrity['state'] ?? 'not_run');
|
||||
$integritySummary = $checksIntegrity['display_summary'] ?? 'Run checks for the current scope before real execution.';
|
||||
$nextAction = $safetyAssessment['primary_next_action'] ?? 'rerun_checks';
|
||||
$nextActionLabel = \App\Support\RestoreSafety\RestoreSafetyCopy::primaryNextAction(is_string($nextAction) ? $nextAction : 'rerun_checks');
|
||||
$startabilitySummary = $executionReadiness['display_summary'] ?? 'Execution readiness is unavailable.';
|
||||
$startabilityTone = (bool) ($executionReadiness['allowed'] ?? false) ? 'success' : 'warning';
|
||||
$limitedList = static function (array $items, int $limit = 5): array {
|
||||
if (count($items) <= $limit) {
|
||||
return $items;
|
||||
}
|
||||
|
||||
return array_slice($items, 0, $limit);
|
||||
};
|
||||
$resultsPresent = collect($groupedResults)
|
||||
->filter(fn ($bucket) => is_array($bucket) && $bucket !== [])
|
||||
->isNotEmpty();
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
<div class="space-y-4">
|
||||
@if ($providerCredentialBlocked)
|
||||
<x-filament::section>
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
Validation blocked
|
||||
</div>
|
||||
<div class="text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
Provider credentials are not available for this environment.
|
||||
</div>
|
||||
<div class="text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
Restore checks cannot run until the provider connection is repaired.
|
||||
</div>
|
||||
|
||||
@if ($providerConnectionsUrl)
|
||||
<div>
|
||||
<x-filament::button
|
||||
tag="a"
|
||||
size="sm"
|
||||
color="primary"
|
||||
:href="$providerConnectionsUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Review provider connection
|
||||
</x-filament::button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
<x-filament::section
|
||||
heading="Safety checks"
|
||||
:description="$ranAtLabel ? ('Last run: ' . $ranAtLabel) : 'Checks tell you whether the current scope can be defended, not just whether it can start.'"
|
||||
@ -62,24 +88,18 @@
|
||||
{{ $integritySpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$startabilityTone" size="sm">
|
||||
{{ (bool) ($executionReadiness['allowed'] ?? false) ? 'Technically startable' : 'Technical blocker present' }}
|
||||
{{ $startabilityTone === 'success' ? 'Prerequisites healthy' : 'Execution blocked' }}
|
||||
</x-filament::badge>
|
||||
@if (($safetyAssessment['state'] ?? null) === 'ready_with_caution')
|
||||
<x-filament::badge color="warning" size="sm">
|
||||
Ready with caution
|
||||
</x-filament::badge>
|
||||
@elseif (($safetyAssessment['state'] ?? null) === 'ready')
|
||||
<x-filament::badge color="success" size="sm">
|
||||
Ready
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3 text-sm text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-100">
|
||||
<div class="font-medium">What the current checks prove</div>
|
||||
<div class="font-medium">What validation proves</div>
|
||||
<div class="mt-1">{{ $integritySummary }}</div>
|
||||
<div class="mt-2 text-xs text-slate-600 dark:text-slate-300">
|
||||
Technical startability: {{ $startabilitySummary }}
|
||||
<div class="mt-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Execution prerequisites
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-slate-600 dark:text-slate-300">
|
||||
{{ $startabilitySummary }}
|
||||
</div>
|
||||
<div class="mt-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Primary next step
|
||||
@ -90,81 +110,82 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::badge :color="$blocking > 0 ? $severitySpec('blocking')->color : 'gray'">
|
||||
{{ $blocking }} {{ \Illuminate\Support\Str::lower($severitySpec('blocking')->label) }}
|
||||
<x-filament::badge :color="$blocking > 0 ? 'danger' : 'gray'">
|
||||
{{ $blocking }} blockers
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$warning > 0 ? $severitySpec('warning')->color : 'gray'">
|
||||
{{ $warning }} {{ \Illuminate\Support\Str::lower($severitySpec('warning')->label) }}
|
||||
<x-filament::badge :color="$warning > 0 ? 'warning' : 'gray'">
|
||||
{{ $warning }} warnings
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$safe > 0 ? $severitySpec('safe')->color : 'gray'">
|
||||
{{ $safe }} {{ \Illuminate\Support\Str::lower($severitySpec('safe')->label) }}
|
||||
<x-filament::badge :color="$safe > 0 ? 'success' : 'gray'">
|
||||
{{ $safe }} safe
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
@if (($checksIntegrity['invalidation_reasons'] ?? []) !== [])
|
||||
@if ($invalidationReasons !== [])
|
||||
<div class="text-xs text-amber-800 dark:text-amber-200">
|
||||
Invalidated by: {{ implode(', ', array_map(static fn (string $reason): string => \Illuminate\Support\Str::replace('_', ' ', $reason), $checksIntegrity['invalidation_reasons'])) }}
|
||||
Invalidated by: {{ implode(', ', array_map(static fn (string $reason): string => \Illuminate\Support\Str::replace('_', ' ', $reason), $invalidationReasons)) }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@if ($results === [])
|
||||
@if (! $resultsPresent)
|
||||
<x-filament::section>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
No checks have been recorded for this scope yet.
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach ($results as $result)
|
||||
<div class="space-y-5">
|
||||
@foreach ([
|
||||
'blocking' => ['label' => 'Blockers', 'tone' => 'danger'],
|
||||
'warning' => ['label' => 'Warnings', 'tone' => 'warning'],
|
||||
'safe' => ['label' => 'Safe checks', 'tone' => 'success'],
|
||||
] as $bucket => $bucketMeta)
|
||||
@php
|
||||
$severity = is_array($result) ? ($result['severity'] ?? 'safe') : 'safe';
|
||||
$title = is_array($result) ? ($result['title'] ?? $result['code'] ?? 'Check') : 'Check';
|
||||
$message = is_array($result) ? ($result['message'] ?? null) : null;
|
||||
$meta = is_array($result) ? ($result['meta'] ?? []) : [];
|
||||
$meta = is_array($meta) ? $meta : [];
|
||||
$unmappedGroups = $meta['unmapped'] ?? [];
|
||||
$unmappedGroups = is_array($unmappedGroups) ? $limitedList($unmappedGroups) : [];
|
||||
$spec = $severitySpec($severity);
|
||||
$bucketResults = is_array($groupedResults[$bucket] ?? null) ? $groupedResults[$bucket] : [];
|
||||
@endphp
|
||||
|
||||
<x-filament::section>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
@if ($bucketResults !== [])
|
||||
<x-filament::section>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ $bucketMeta['label'] }}
|
||||
</div>
|
||||
@if (is_string($message) && $message !== '')
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
<x-filament::badge :color="$bucketMeta['tone']" size="sm">
|
||||
{{ count($bucketResults) }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-3">
|
||||
@foreach ($bucketResults as $result)
|
||||
@php
|
||||
$title = is_array($result) ? ($result['title'] ?? $result['code'] ?? 'Check') : 'Check';
|
||||
$message = is_array($result) ? ($result['message'] ?? null) : null;
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ is_string($title) ? $title : 'Check' }}
|
||||
</div>
|
||||
@if (is_string($message) && $message !== '')
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<x-filament::badge :color="$bucketMeta['tone']" size="sm">
|
||||
{{ $bucketMeta['label'] }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<x-filament::badge :color="$spec->color" :icon="$spec->icon" size="sm">
|
||||
{{ $spec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
@if ($unmappedGroups !== [])
|
||||
<div class="mt-3">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Unmapped groups
|
||||
</div>
|
||||
<ul class="mt-2 space-y-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
@foreach ($unmappedGroups as $group)
|
||||
@php
|
||||
$label = is_array($group) ? ($group['label'] ?? $group['id'] ?? null) : null;
|
||||
@endphp
|
||||
@if (is_string($label) && $label !== '')
|
||||
<li>{{ $label }}</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
@php
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
|
||||
$decisionCard = is_array($decisionCard ?? null) ? $decisionCard : [];
|
||||
$processFlow = is_array($processFlow ?? null) ? $processFlow : [];
|
||||
|
||||
$statusTone = (string) ($decisionCard['tone'] ?? 'gray');
|
||||
|
||||
$statusBadgeClasses = static function (string $tone): string {
|
||||
return match ($tone) {
|
||||
'success' => 'border-success-200 bg-success-50 text-success-700 dark:border-success-700 dark:bg-success-950/30 dark:text-success-300',
|
||||
'warning' => 'border-warning-200 bg-warning-50 text-warning-700 dark:border-warning-700 dark:bg-warning-950/30 dark:text-warning-300',
|
||||
'danger' => 'border-danger-200 bg-danger-50 text-danger-700 dark:border-danger-700 dark:bg-danger-950/30 dark:text-danger-300',
|
||||
default => 'border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300',
|
||||
};
|
||||
};
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
<x-filament::section heading="Confirmation summary" description="Execution only becomes available after high-friction confirmation. Proof remains unavailable until a run starts.">
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ $decisionCard['title'] ?? 'Restore Safety' }}
|
||||
</div>
|
||||
<span class="inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold {{ $statusBadgeClasses($statusTone) }}">
|
||||
{{ $decisionCard['status'] ?? 'Unavailable' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
||||
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||||
Primary next step
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ $decisionCard['nextAction'] ?? 'Review the current restore state.' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
||||
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||||
Next gate
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ $processFlow['nextGate'] ?? 'Unavailable' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
||||
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||||
Execution
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ $processFlow['executionLabel'] ?? 'Unavailable' }}
|
||||
</div>
|
||||
@if (filled($processFlow['executionSummary'] ?? null))
|
||||
<div class="mt-2 text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
{{ $processFlow['executionSummary'] }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-warning-200 bg-warning-50 px-4 py-3 text-sm text-warning-900 dark:border-warning-700 dark:bg-warning-950/30 dark:text-warning-100">
|
||||
Confirmation does not claim recovery. Operation proof and post-run evidence remain unavailable until execution starts.
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-dynamic-component>
|
||||
@ -0,0 +1,12 @@
|
||||
@php
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
$identityHtml = $identityHtml ?? null;
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
<div class="text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
@if ($identityHtml)
|
||||
{!! $identityHtml !!}
|
||||
@endif
|
||||
</div>
|
||||
</x-dynamic-component>
|
||||
@ -0,0 +1,98 @@
|
||||
@php
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
@php
|
||||
$mappingResolver = is_array($mappingResolver ?? null) ? $mappingResolver : [];
|
||||
$resolvedCount = (int) ($mappingResolver['resolvedCount'] ?? 0);
|
||||
$totalCount = (int) ($mappingResolver['totalCount'] ?? 0);
|
||||
$unresolvedCount = (int) ($mappingResolver['unresolvedCount'] ?? 0);
|
||||
$skippedCount = (int) ($mappingResolver['skippedCount'] ?? 0);
|
||||
$manualFallbackCount = (int) ($mappingResolver['manualFallbackCount'] ?? 0);
|
||||
$requirementLabel = (string) ($mappingResolver['requirementLabel'] ?? 'Required before validation can run');
|
||||
$explanation = (string) ($mappingResolver['explanation'] ?? 'Select a target group from the directory cache or enter a target group object ID as a fallback. Required mappings must be resolved before validation can run.');
|
||||
$cacheNotice = (string) ($mappingResolver['cacheNotice'] ?? '');
|
||||
$groupSyncUrl = $mappingResolver['groupSyncUrl'] ?? null;
|
||||
$groupSyncOperationsUrl = $mappingResolver['groupSyncOperationsUrl'] ?? null;
|
||||
@endphp
|
||||
|
||||
<div data-testid="restore-run-mapping-resolver-summary" class="space-y-4 rounded-lg border border-gray-200 bg-gray-50/70 p-4 dark:border-gray-800 dark:bg-gray-900/40">
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="inline-flex items-center rounded-full border border-primary-200 bg-primary-50 px-3 py-1 text-xs font-semibold text-primary-700 dark:border-primary-700 dark:bg-primary-950/30 dark:text-primary-300">
|
||||
{{ $resolvedCount }} of {{ $totalCount }} mappings resolved
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full border border-warning-200 bg-warning-50 px-3 py-1 text-xs font-semibold text-warning-700 dark:border-warning-700 dark:bg-warning-950/30 dark:text-warning-300">
|
||||
{{ $unresolvedCount }} unresolved
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300">
|
||||
{{ $skippedCount }} skipped
|
||||
</span>
|
||||
@if ($manualFallbackCount > 0)
|
||||
<span class="inline-flex items-center rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300">
|
||||
{{ $manualFallbackCount }} manual fallback
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ $requirementLabel }}
|
||||
</p>
|
||||
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
{{ $explanation }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if ($cacheNotice !== '')
|
||||
<div class="rounded-lg border border-warning-200 bg-warning-50/80 px-4 py-3 text-sm leading-6 text-warning-800 dark:border-warning-700 dark:bg-warning-950/20 dark:text-warning-200">
|
||||
{{ $cacheNotice }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::button
|
||||
size="sm"
|
||||
color="gray"
|
||||
type="button"
|
||||
data-testid="restore-run-hide-mapping-details"
|
||||
x-on:click="
|
||||
const section = $el.closest('.fi-section');
|
||||
section?.querySelector('.fi-section-header')?.click();
|
||||
"
|
||||
>
|
||||
Hide mapping details
|
||||
</x-filament::button>
|
||||
|
||||
@if (filled($groupSyncUrl))
|
||||
<x-filament::button
|
||||
size="sm"
|
||||
color="warning"
|
||||
tag="a"
|
||||
:href="$groupSyncUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="restore-run-open-group-sync"
|
||||
>
|
||||
Open group sync
|
||||
</x-filament::button>
|
||||
@endif
|
||||
|
||||
@if (filled($groupSyncOperationsUrl))
|
||||
<x-filament::button
|
||||
size="sm"
|
||||
color="gray"
|
||||
tag="a"
|
||||
:href="$groupSyncOperationsUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="restore-run-view-group-sync-operations"
|
||||
>
|
||||
View group sync operations
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-dynamic-component>
|
||||
@ -1,228 +1,228 @@
|
||||
@php
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
|
||||
$diffs = $getState() ?? [];
|
||||
$diffs = is_array($diffs) ? $diffs : [];
|
||||
$previewState = is_array($previewSummary ?? null) ? $previewSummary : [];
|
||||
$previewState = $previewState !== []
|
||||
? $previewState
|
||||
: (is_array($preview_summary ?? null) ? $preview_summary : []);
|
||||
|
||||
$summary = $summary ?? [];
|
||||
$summary = is_array($summary) ? $summary : [];
|
||||
$integritySpec = $previewState['integritySpec'] ?? null;
|
||||
|
||||
$previewIntegrity = $previewIntegrity ?? [];
|
||||
$previewIntegrity = is_array($previewIntegrity) ? $previewIntegrity : [];
|
||||
if (! $integritySpec instanceof \App\Support\Badges\BadgeSpec) {
|
||||
$integritySpec = \App\Support\Badges\BadgeSpec::unknown();
|
||||
}
|
||||
|
||||
$checksIntegrity = $checksIntegrity ?? [];
|
||||
$checksIntegrity = is_array($checksIntegrity) ? $checksIntegrity : [];
|
||||
$generatedAt = $previewState['generatedAt'] ?? null;
|
||||
$generatedAtLabel = null;
|
||||
|
||||
$safetyAssessment = $safetyAssessment ?? [];
|
||||
$safetyAssessment = is_array($safetyAssessment) ? $safetyAssessment : [];
|
||||
|
||||
$ranAt = $ranAt ?? ($previewIntegrity['generated_at'] ?? null);
|
||||
$ranAtLabel = null;
|
||||
|
||||
if (is_string($ranAt) && $ranAt !== '') {
|
||||
if (is_string($generatedAt) && $generatedAt !== '') {
|
||||
try {
|
||||
$ranAtLabel = \Carbon\CarbonImmutable::parse($ranAt)->format('Y-m-d H:i');
|
||||
$generatedAtLabel = \Carbon\CarbonImmutable::parse($generatedAt)->format('Y-m-d H:i');
|
||||
} catch (\Throwable) {
|
||||
$ranAtLabel = $ranAt;
|
||||
$generatedAtLabel = $generatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
$integritySpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::RestorePreviewDecision,
|
||||
$previewIntegrity['state'] ?? 'not_generated'
|
||||
);
|
||||
$integritySummary = (string) ($previewState['integritySummary'] ?? 'Generate a preview before real execution.');
|
||||
$scopeLabel = (string) ($previewState['scopeLabel'] ?? 'Restore scope');
|
||||
$primaryNextActionLabel = (string) ($previewState['primaryNextActionLabel'] ?? 'Review the current scope and safety evidence.');
|
||||
|
||||
$summary = is_array($previewState['previewSummary'] ?? null) ? $previewState['previewSummary'] : [];
|
||||
$needsAttentionDiffs = is_array($previewState['needsAttentionDiffs'] ?? null) ? $previewState['needsAttentionDiffs'] : [];
|
||||
$unchangedDiffs = is_array($previewState['unchangedDiffs'] ?? null) ? $previewState['unchangedDiffs'] : [];
|
||||
|
||||
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
|
||||
$policiesChanged = (int) ($summary['policies_changed'] ?? 0);
|
||||
$assignmentsChanged = (int) ($summary['assignments_changed'] ?? 0);
|
||||
$scopeTagsChanged = (int) ($summary['scope_tags_changed'] ?? 0);
|
||||
$diffsOmitted = (int) ($summary['diffs_omitted'] ?? 0);
|
||||
$integritySummary = $previewIntegrity['display_summary'] ?? 'Generate a preview before real execution.';
|
||||
$nextAction = $safetyAssessment['primary_next_action'] ?? 'generate_preview';
|
||||
$nextActionLabel = \App\Support\RestoreSafety\RestoreSafetyCopy::primaryNextAction(is_string($nextAction) ? $nextAction : 'generate_preview');
|
||||
$limitedKeys = static function (array $items, int $limit = 8): array {
|
||||
$keys = array_keys($items);
|
||||
|
||||
if (count($keys) <= $limit) {
|
||||
return $keys;
|
||||
$reviewedCount = count($needsAttentionDiffs) + count($unchangedDiffs);
|
||||
|
||||
$policyLabel = static function (array $entry): string {
|
||||
$displayName = $entry['display_name'] ?? $entry['displayName'] ?? null;
|
||||
$identifier = $entry['policy_identifier'] ?? $entry['policyIdentifier'] ?? null;
|
||||
|
||||
if (is_string($displayName) && trim($displayName) !== '') {
|
||||
return (string) \Illuminate\Support\Str::of(trim($displayName))
|
||||
->headline()
|
||||
->replaceMatches('/\\bbitlocker\\b/i', 'BitLocker');
|
||||
}
|
||||
|
||||
return array_slice($keys, 0, $limit);
|
||||
if (is_string($identifier) && trim($identifier) !== '') {
|
||||
return (string) \Illuminate\Support\Str::of(trim($identifier))
|
||||
->headline()
|
||||
->replaceMatches('/\\bbitlocker\\b/i', 'BitLocker');
|
||||
}
|
||||
|
||||
return 'Policy';
|
||||
};
|
||||
|
||||
$policyActionLabel = static function (array $entry): string {
|
||||
$action = $entry['action'] ?? null;
|
||||
|
||||
return match ((string) $action) {
|
||||
'create' => 'Create',
|
||||
'delete' => 'Delete',
|
||||
'update' => 'Update',
|
||||
default => 'Review',
|
||||
};
|
||||
};
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
<div class="space-y-4">
|
||||
<x-filament::section
|
||||
heading="Preview"
|
||||
:description="$ranAtLabel ? ('Generated: ' . $ranAtLabel) : 'Preview answers what would change for the current scope.'"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::badge :color="$integritySpec->color" :icon="$integritySpec->icon" size="sm">
|
||||
{{ $integritySpec->label }}
|
||||
</x-filament::badge>
|
||||
@if (($checksIntegrity['state'] ?? null) === 'current')
|
||||
<x-filament::badge color="success" size="sm">
|
||||
Checks current
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-12">
|
||||
<div class="space-y-4 lg:col-span-8">
|
||||
<x-filament::section
|
||||
heading="Preview"
|
||||
:description="$generatedAtLabel ? ('Generated: ' . $generatedAtLabel) : 'Preview answers what would change for the current scope.'"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::badge :color="$integritySpec->color" :icon="$integritySpec->icon" size="sm">
|
||||
{{ $integritySpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@if (($safetyAssessment['state'] ?? null) === 'ready_with_caution')
|
||||
<x-filament::badge color="warning" size="sm">
|
||||
Calm readiness suppressed
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ $scopeLabel }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3 text-sm text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-100">
|
||||
<div class="font-medium">What the preview proves</div>
|
||||
<div class="mt-1">{{ $integritySummary }}</div>
|
||||
<div class="mt-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Primary next step
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-slate-600 dark:text-slate-300">
|
||||
{{ $nextActionLabel }}
|
||||
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3 text-sm text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-100">
|
||||
<div class="font-medium">What the preview proves</div>
|
||||
<div class="mt-1">{{ $integritySummary }}</div>
|
||||
<div class="mt-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Primary next step
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-slate-600 dark:text-slate-300">
|
||||
{{ $primaryNextActionLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::badge :color="$policiesChanged > 0 ? 'warning' : 'success'">
|
||||
{{ $policiesChanged }}/{{ $policiesTotal }} policies changed
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$assignmentsChanged > 0 ? 'warning' : 'gray'">
|
||||
{{ $assignmentsChanged }} assignments changed
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$scopeTagsChanged > 0 ? 'warning' : 'gray'">
|
||||
{{ $scopeTagsChanged }} scope tags changed
|
||||
</x-filament::badge>
|
||||
@if ($diffsOmitted > 0)
|
||||
<x-filament::badge color="gray">
|
||||
{{ $diffsOmitted }} diffs omitted (limit)
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (($previewIntegrity['invalidation_reasons'] ?? []) !== [])
|
||||
<div class="text-xs text-amber-800 dark:text-amber-200">
|
||||
Invalidated by: {{ implode(', ', array_map(static fn (string $reason): string => \Illuminate\Support\Str::replace('_', ' ', $reason), $previewIntegrity['invalidation_reasons'])) }}
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
||||
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">Policies reviewed</div>
|
||||
<div class="mt-1 text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ $reviewedCount }} {{ \Illuminate\Support\Str::plural('policy', $reviewedCount) }} reviewed
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
||||
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">Policies changed</div>
|
||||
<div class="mt-1 text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ $policiesChanged }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
||||
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">Assignments changed</div>
|
||||
<div class="mt-1 text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ $assignmentsChanged }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
||||
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">Scope tags changed</div>
|
||||
<div class="mt-1 text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ $scopeTagsChanged }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@if ($diffs === [])
|
||||
<x-filament::section>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
No preview diff is recorded for this scope yet.
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach ($diffs as $entry)
|
||||
@php
|
||||
$entry = is_array($entry) ? $entry : [];
|
||||
$name = $entry['display_name'] ?? $entry['policy_identifier'] ?? 'Item';
|
||||
$type = $entry['policy_type'] ?? 'type';
|
||||
$platform = $entry['platform'] ?? 'platform';
|
||||
$action = $entry['action'] ?? 'update';
|
||||
$diff = is_array($entry['diff'] ?? null) ? $entry['diff'] : [];
|
||||
$diffSummary = is_array($diff['summary'] ?? null) ? $diff['summary'] : [];
|
||||
$added = (int) ($diffSummary['added'] ?? 0);
|
||||
$removed = (int) ($diffSummary['removed'] ?? 0);
|
||||
$changed = (int) ($diffSummary['changed'] ?? 0);
|
||||
$assignmentsDelta = (bool) ($entry['assignments_changed'] ?? false);
|
||||
$scopeTagsDelta = (bool) ($entry['scope_tags_changed'] ?? false);
|
||||
$diffOmitted = (bool) ($entry['diff_omitted'] ?? false);
|
||||
$diffTruncated = (bool) ($entry['diff_truncated'] ?? false);
|
||||
$changedKeys = $limitedKeys(is_array($diff['changed'] ?? null) ? $diff['changed'] : []);
|
||||
$addedKeys = $limitedKeys(is_array($diff['added'] ?? null) ? $diff['added'] : []);
|
||||
$removedKeys = $limitedKeys(is_array($diff['removed'] ?? null) ? $diff['removed'] : []);
|
||||
@endphp
|
||||
|
||||
<x-filament::section :heading="$name" :description="sprintf('%s • %s', $type, $platform)" collapsible :collapsed="true">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::badge :color="$action === 'create' ? 'success' : 'gray'" size="sm">
|
||||
{{ $action }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="success" size="sm">
|
||||
{{ $added }} added
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="danger" size="sm">
|
||||
{{ $removed }} removed
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="warning" size="sm">
|
||||
{{ $changed }} changed
|
||||
</x-filament::badge>
|
||||
@if ($assignmentsDelta)
|
||||
<x-filament::badge color="warning" size="sm">
|
||||
assignments
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@if ($scopeTagsDelta)
|
||||
<x-filament::badge color="warning" size="sm">
|
||||
scope tags
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@if ($diffTruncated)
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
truncated
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
<x-filament::section heading="Policy change preview">
|
||||
<div class="space-y-4">
|
||||
@if ($policiesTotal === 0)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
No policies are included in this preview yet.
|
||||
</div>
|
||||
@elseif ($needsAttentionDiffs === [] && $unchangedDiffs !== [])
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-800 dark:border-gray-800 dark:bg-gray-950/50 dark:text-gray-200">
|
||||
No policy changes
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($diffOmitted)
|
||||
<div class="mt-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
Diff details omitted due to preview limits. Narrow scope to see more items in detail.
|
||||
@if ($diffsOmitted > 0)
|
||||
<div class="rounded-lg border border-warning-200 bg-warning-50/80 px-4 py-3 text-sm leading-6 text-warning-800 dark:border-warning-700 dark:bg-warning-950/20 dark:text-warning-200">
|
||||
{{ $diffsOmitted }} {{ \Illuminate\Support\Str::plural('policy diff', $diffsOmitted) }} omitted due to preview limits. Narrow scope to review more.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($needsAttentionDiffs !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
Needs attention
|
||||
</div>
|
||||
@elseif ($changedKeys !== [] || $addedKeys !== [] || $removedKeys !== [])
|
||||
<div class="mt-3 space-y-3 text-sm text-gray-700 dark:text-gray-200">
|
||||
@if ($changedKeys !== [])
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Changed keys (sample)
|
||||
</div>
|
||||
<ul class="mt-1 space-y-1">
|
||||
@foreach ($changedKeys as $key)
|
||||
<li class="rounded bg-gray-50 px-2 py-1 text-xs text-gray-800 dark:bg-white/5 dark:text-gray-200">
|
||||
{{ $key }}
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
@if ($addedKeys !== [])
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Added keys (sample)
|
||||
</div>
|
||||
<ul class="mt-1 space-y-1">
|
||||
@foreach ($addedKeys as $key)
|
||||
<li class="rounded bg-gray-50 px-2 py-1 text-xs text-gray-800 dark:bg-white/5 dark:text-gray-200">
|
||||
{{ $key }}
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
@if ($removedKeys !== [])
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Removed keys (sample)
|
||||
</div>
|
||||
<ul class="mt-1 space-y-1">
|
||||
@foreach ($removedKeys as $key)
|
||||
<li class="rounded bg-gray-50 px-2 py-1 text-xs text-gray-800 dark:bg-white/5 dark:text-gray-200">
|
||||
{{ $key }}
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-800">
|
||||
<thead class="bg-gray-50 dark:bg-gray-950/40">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Policy</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
@foreach ($needsAttentionDiffs as $entry)
|
||||
@php
|
||||
$entry = is_array($entry) ? $entry : [];
|
||||
@endphp
|
||||
<tr>
|
||||
<td class="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $policyLabel($entry) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">
|
||||
{{ $policyActionLabel($entry) }}
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($unchangedDiffs !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
Unchanged
|
||||
</div>
|
||||
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-800">
|
||||
<thead class="bg-gray-50 dark:bg-gray-950/40">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Policy</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
@foreach ($unchangedDiffs as $entry)
|
||||
@php
|
||||
$entry = is_array($entry) ? $entry : [];
|
||||
@endphp
|
||||
<tr>
|
||||
<td class="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $policyLabel($entry) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">
|
||||
No policy changes
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 lg:col-span-4">
|
||||
@include('filament.forms.components.partials.restore-run-process-flow-panel', [
|
||||
'processFlow' => $processFlow ?? [],
|
||||
])
|
||||
|
||||
@include('filament.forms.components.partials.restore-run-proof-panel', [
|
||||
'proofAside' => $proofAside ?? [],
|
||||
'diagnosticsDisclosure' => $diagnosticsDisclosure ?? [],
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
</x-dynamic-component>
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
@php
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
@include('filament.forms.components.partials.restore-run-proof-panel', [
|
||||
'proofAside' => $proofAside ?? [],
|
||||
'diagnosticsDisclosure' => $diagnosticsDisclosure ?? [],
|
||||
])
|
||||
</x-dynamic-component>
|
||||
@ -0,0 +1,62 @@
|
||||
@php
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
$decisionCard = is_array($decisionCard ?? null) ? $decisionCard : [];
|
||||
|
||||
$statusBadgeClasses = static function (string $tone): string {
|
||||
return match ($tone) {
|
||||
'success' => 'border-success-200 bg-success-50 text-success-700 dark:border-success-700 dark:bg-success-950/30 dark:text-success-300',
|
||||
'warning' => 'border-warning-200 bg-warning-50 text-warning-700 dark:border-warning-700 dark:bg-warning-950/30 dark:text-warning-300',
|
||||
'danger' => 'border-danger-200 bg-danger-50 text-danger-700 dark:border-danger-700 dark:bg-danger-950/30 dark:text-danger-300',
|
||||
default => 'border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300',
|
||||
};
|
||||
};
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
<x-filament::section>
|
||||
<div data-testid="restore-run-decision-card" class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_20rem]">
|
||||
<div class="min-w-0 space-y-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="text-base font-semibold text-gray-950 dark:text-white">
|
||||
{{ $decisionCard['title'] ?? 'Restore Safety' }}
|
||||
</h2>
|
||||
<span class="inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold {{ $statusBadgeClasses((string) ($decisionCard['tone'] ?? 'gray')) }}">
|
||||
{{ $decisionCard['status'] ?? 'Unavailable' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl class="grid gap-3 md:grid-cols-3">
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
|
||||
<dt class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $decisionCard['statusLabel'] ?? 'Status' }}</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">{{ $decisionCard['status'] ?? 'Unavailable' }}</dd>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
|
||||
<dt class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $decisionCard['reasonLabel'] ?? 'Reason' }}</dt>
|
||||
<dd class="mt-1 text-sm text-gray-700 dark:text-gray-200">{{ $decisionCard['reason'] ?? 'Restore safety reason is unavailable.' }}</dd>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
|
||||
<dt class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $decisionCard['impactLabel'] ?? 'Impact' }}</dt>
|
||||
<dd class="mt-1 text-sm text-gray-700 dark:text-gray-200">{{ $decisionCard['impact'] ?? 'Restore impact is unavailable.' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<aside class="min-w-0">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
||||
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $decisionCard['nextActionLabel'] ?? 'Primary next action' }}</div>
|
||||
<div data-testid="restore-run-decision-next-action" class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ $decisionCard['nextAction'] ?? 'Review the current restore state.' }}
|
||||
</div>
|
||||
|
||||
@if (filled($decisionCard['helperText'] ?? null))
|
||||
<p class="mt-3 text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
{{ $decisionCard['helperText'] }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-dynamic-component>
|
||||
@ -0,0 +1,9 @@
|
||||
@php
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
@include('filament.forms.components.partials.restore-run-process-flow-panel', [
|
||||
'processFlow' => $processFlow ?? [],
|
||||
])
|
||||
</x-dynamic-component>
|
||||
@ -0,0 +1,98 @@
|
||||
@php
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
|
||||
$currentScope = is_array($currentScope ?? null) ? $currentScope : [];
|
||||
$mappingResolver = is_array($mappingResolver ?? null) ? $mappingResolver : [];
|
||||
|
||||
$scopeMode = ($currentScope['scope_mode'] ?? null) === 'selected' ? 'selected' : 'all';
|
||||
$selectedItems = is_array($currentScope['selected_item_ids'] ?? null) ? $currentScope['selected_item_ids'] : [];
|
||||
$selectedCount = count($selectedItems);
|
||||
|
||||
$resolvedCount = (int) ($mappingResolver['resolvedCount'] ?? 0);
|
||||
$totalCount = (int) ($mappingResolver['totalCount'] ?? 0);
|
||||
$unresolvedCount = (int) ($mappingResolver['unresolvedCount'] ?? 0);
|
||||
$skippedCount = (int) ($mappingResolver['skippedCount'] ?? 0);
|
||||
$manualFallbackCount = (int) ($mappingResolver['manualFallbackCount'] ?? 0);
|
||||
|
||||
$blockedReason = $blocked_reason ?? null;
|
||||
$blockedReason = is_string($blockedReason) && $blockedReason !== '' ? $blockedReason : null;
|
||||
|
||||
$canContinue = $can_continue ?? null;
|
||||
$canContinue = is_bool($canContinue) ? $canContinue : null;
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
<x-filament::section heading="Scope summary" description="Define the scope, then resolve any required dependency mappings before validation.">
|
||||
<div data-testid="restore-run-scope-summary" class="space-y-4">
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
||||
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||||
Selected scope
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ $scopeMode === 'selected' ? "{$selectedCount} selected item" . ($selectedCount === 1 ? '' : 's') : 'All items (default)' }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
Scope changes invalidate checks and preview evidence.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
||||
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||||
Dependency mappings
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
@if ($totalCount === 0)
|
||||
No mappings required
|
||||
@else
|
||||
{{ $resolvedCount }} of {{ $totalCount }} mappings resolved
|
||||
@endif
|
||||
</div>
|
||||
@if ($totalCount > 0)
|
||||
<div class="mt-2 flex flex-wrap gap-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span>{{ $unresolvedCount }} unresolved</span>
|
||||
<span>·</span>
|
||||
<span>{{ $skippedCount }} skipped</span>
|
||||
@if ($manualFallbackCount > 0)
|
||||
<span>·</span>
|
||||
<span>{{ $manualFallbackCount }} manual fallback</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
||||
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||||
Next
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ $canContinue === true ? 'Ready to continue' : 'Blocked' }}
|
||||
</div>
|
||||
@if ($blockedReason)
|
||||
<div class="mt-1 text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
{{ $blockedReason }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($totalCount > 0)
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="restore-run-open-mapping-resolver"
|
||||
class="fi-color fi-color-primary fi-bg-color-400 hover:fi-bg-color-300 dark:fi-bg-color-600 dark:hover:fi-bg-color-500 fi-text-color-900 hover:fi-text-color-800 dark:fi-text-color-950 dark:hover:fi-text-color-950 fi-btn fi-size-sm"
|
||||
wire:loading.attr="disabled"
|
||||
x-on:click="
|
||||
const section = document.querySelector('[data-testid=restore-run-mapping-resolver-section]');
|
||||
section?.scrollIntoView();
|
||||
section?.querySelector('.fi-section-header')?.click();
|
||||
"
|
||||
>
|
||||
Resolve mappings
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-dynamic-component>
|
||||
@ -1,3 +1,3 @@
|
||||
<div class="space-y-4">
|
||||
<livewire:entra-group-cache-picker-table :sourceGroupId="$sourceGroupId" />
|
||||
<livewire:entra-group-cache-picker-table :sourceGroupId="$sourceGroupId" :sourceGroupDisplayName="$sourceGroupDisplayName ?? null" :tenantId="$tenantId ?? null" />
|
||||
</div>
|
||||
|
||||
@ -1,3 +1,68 @@
|
||||
<div class="space-y-2">
|
||||
{{ $this->table }}
|
||||
<div class="space-y-4" data-testid="restore-group-picker-modal-content">
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/80" data-testid="restore-group-picker-context">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">
|
||||
Source group
|
||||
</p>
|
||||
<p class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100" data-testid="restore-group-picker-source-name">
|
||||
{{ $sourceGroupDisplayName ?? 'Unknown source group' }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-gray-700 dark:text-gray-200" data-testid="restore-group-picker-source-id">
|
||||
Source ID: {{ $sourceGroupId }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if ($hasCachedGroups)
|
||||
<div class="space-y-2" data-testid="restore-group-picker-table">
|
||||
{{ $this->table }}
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-xl border border-dashed border-warning-300 bg-warning-50/70 p-6 dark:border-warning-700 dark:bg-warning-950/25" data-testid="restore-group-picker-empty-state">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-950 dark:text-white">
|
||||
No directory group cache available
|
||||
</h3>
|
||||
<p class="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
TenantPilot needs cached directory groups before target mappings can be selected.
|
||||
</p>
|
||||
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
Sync directory groups, then return to this mapping.
|
||||
</p>
|
||||
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
If you already know the target object ID, close this picker and enter it manually in the mapping field.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
@if (filled($groupSyncUrl))
|
||||
<x-filament::button
|
||||
data-testid="restore-group-picker-open-sync"
|
||||
:href="$groupSyncUrl"
|
||||
tag="a"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
color="primary"
|
||||
size="sm"
|
||||
>
|
||||
Open group sync
|
||||
</x-filament::button>
|
||||
@endif
|
||||
|
||||
@if (filled($groupSyncOperationsUrl))
|
||||
<x-filament::button
|
||||
data-testid="restore-group-picker-view-operations"
|
||||
:href="$groupSyncOperationsUrl"
|
||||
tag="a"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
color="gray"
|
||||
size="sm"
|
||||
>
|
||||
View group sync operations
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,382 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\User;
|
||||
|
||||
pest()->browser()->timeout(60_000);
|
||||
|
||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||
|
||||
function spec332RestoreWizardScreenshot(string $name): string
|
||||
{
|
||||
return 'spec332-restore-create-'.$name;
|
||||
}
|
||||
|
||||
function spec332CopyBrowserScreenshot(string $name, ?string $targetFilename = null): void
|
||||
{
|
||||
$filename = spec332RestoreWizardScreenshot($name).'.png';
|
||||
$source = base_path('tests/Browser/Screenshots/'.$filename);
|
||||
$targetDirectory = repo_path('specs/332-product-process-flow-system-v1/artifacts/screenshots');
|
||||
$targetFilename ??= $filename;
|
||||
|
||||
if (is_dir($targetDirectory) && ! is_writable($targetDirectory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_dir($targetDirectory)) {
|
||||
@mkdir($targetDirectory, 0755, true);
|
||||
}
|
||||
|
||||
if (! is_dir($targetDirectory) || ! is_writable($targetDirectory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_file($source)) {
|
||||
$source = \Pest\Browser\Support\Screenshot::path($filename);
|
||||
}
|
||||
|
||||
if (is_file($source)) {
|
||||
@copy($source, $targetDirectory.DIRECTORY_SEPARATOR.$targetFilename);
|
||||
}
|
||||
}
|
||||
|
||||
function spec332RestoreWizardScreenshotsLoginUrl(User $user, ManagedEnvironment $tenant, string $redirect = ''): string
|
||||
{
|
||||
return route('admin.local.smoke-login', array_filter([
|
||||
'email' => $user->email,
|
||||
'tenant' => $tenant->external_id,
|
||||
'workspace' => $tenant->workspace->slug,
|
||||
'redirect' => $redirect,
|
||||
], static fn (?string $value): bool => filled($value)));
|
||||
}
|
||||
|
||||
function spec332ScreenshotsTenant(): array
|
||||
{
|
||||
$tenant = ManagedEnvironment::factory()->create([
|
||||
'rbac_status' => 'ok',
|
||||
'rbac_last_checked_at' => now(),
|
||||
]);
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
ensureDefaultProviderConnection($tenant, 'microsoft');
|
||||
bindFailHardGraphClient();
|
||||
|
||||
return [$user, $tenant];
|
||||
}
|
||||
|
||||
function spec332ScreenshotsRedirect(ManagedEnvironment $tenant): string
|
||||
{
|
||||
$redirectBase = RestoreRunResource::getUrl('create', panel: 'admin', tenant: $tenant);
|
||||
|
||||
return parse_url($redirectBase, PHP_URL_PATH) ?: '/admin';
|
||||
}
|
||||
|
||||
function spec332ScreenshotsSelectBackupSet($page, BackupSet $backupSet): void
|
||||
{
|
||||
$selected = $page->script(<<<JS
|
||||
(() => {
|
||||
const select = document.getElementById('form.backup_set_id');
|
||||
|
||||
if (! select) {
|
||||
return false;
|
||||
}
|
||||
|
||||
select.value = '{$backupSet->getKey()}';
|
||||
select.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
return true;
|
||||
})()
|
||||
JS);
|
||||
|
||||
expect($selected)->toBeTrue();
|
||||
}
|
||||
|
||||
function spec332ScreenshotsWizardNext($page): void
|
||||
{
|
||||
$clicked = $page->script(<<<'JS'
|
||||
(() => {
|
||||
const footer = document.querySelector('.fi-sc-wizard-footer');
|
||||
|
||||
if (! footer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextTrigger = footer.querySelector('div[x-on\\:click*="requestNextStep"]');
|
||||
|
||||
if (! nextTrigger) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nextTrigger.classList.contains('fi-hidden')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
nextTrigger.scrollIntoView({ block: 'center' });
|
||||
nextTrigger.click();
|
||||
|
||||
return true;
|
||||
})()
|
||||
JS);
|
||||
|
||||
expect($clicked)->toBeTrue();
|
||||
}
|
||||
|
||||
function spec332ScreenshotsUsableBackupFixture(ManagedEnvironment $tenant): BackupSet
|
||||
{
|
||||
$policy = Policy::create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'spec332-screenshots-policy-usable',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Spec332 Screenshots Policy',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
PolicyVersion::create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'captured_at' => now(),
|
||||
'snapshot' => [
|
||||
'foo' => 'current',
|
||||
],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Spec332 Screenshots Usable Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
BackupItem::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_identifier' => $policy->external_id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'captured_at' => now(),
|
||||
'payload' => [
|
||||
'foo' => 'backup',
|
||||
'displayName' => 'Spec332 Screenshots Policy',
|
||||
],
|
||||
'assignments' => [],
|
||||
'metadata' => [
|
||||
'displayName' => 'Spec332 Screenshots Policy',
|
||||
],
|
||||
]);
|
||||
|
||||
return $backupSet;
|
||||
}
|
||||
|
||||
function spec332ScreenshotsUnresolvedGroupBackupFixture(ManagedEnvironment $tenant): BackupSet
|
||||
{
|
||||
$policy = Policy::create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'spec332-screenshots-policy-group',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Spec332 Screenshots Group Mapping Policy',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Spec332 Screenshots Group Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
BackupItem::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_identifier' => $policy->external_id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'captured_at' => now(),
|
||||
'payload' => [
|
||||
'foo' => 'backup',
|
||||
'displayName' => 'Spec332 Screenshots Group Mapping Policy',
|
||||
],
|
||||
'assignments' => [[
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => '11111111-1111-1111-1111-111111111111',
|
||||
'group_display_name' => 'Spec332 Missing Group',
|
||||
],
|
||||
]],
|
||||
'metadata' => [
|
||||
'displayName' => 'Spec332 Screenshots Group Mapping Policy',
|
||||
],
|
||||
]);
|
||||
|
||||
return $backupSet;
|
||||
}
|
||||
|
||||
function spec332ScreenshotsMetadataOnlyFixture(ManagedEnvironment $tenant): BackupSet
|
||||
{
|
||||
$policy = Policy::create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'spec332-screenshots-policy-metadata-only',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Spec332 Screenshots Metadata Only Policy',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Spec332 Screenshots Metadata-only Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
BackupItem::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_identifier' => $policy->external_id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'captured_at' => now(),
|
||||
'payload' => [],
|
||||
'assignments' => [],
|
||||
'metadata' => [
|
||||
'displayName' => 'Spec332 Screenshots Metadata Only Policy',
|
||||
'snapshot_source' => 'metadata_only',
|
||||
'warnings' => ['metadata only fallback'],
|
||||
],
|
||||
]);
|
||||
|
||||
return $backupSet;
|
||||
}
|
||||
|
||||
it('captures step 1 screenshot with a degraded backup selected', function (): void {
|
||||
[$user, $tenant] = spec332ScreenshotsTenant();
|
||||
$backupSet = spec332ScreenshotsMetadataOnlyFixture($tenant);
|
||||
|
||||
$page = visit(spec332RestoreWizardScreenshotsLoginUrl($user, $tenant, spec332ScreenshotsRedirect($tenant)));
|
||||
|
||||
$page->resize(1920, 1200)
|
||||
->waitForText('Select Backup Set');
|
||||
|
||||
spec332ScreenshotsSelectBackupSet($page, $backupSet);
|
||||
|
||||
$page->waitForText('The selected backup does not contain a usable captured item yet.');
|
||||
$page->script('window.scrollTo(0, 0);');
|
||||
$page->screenshot(true, spec332RestoreWizardScreenshot('step-1-backup-selected'));
|
||||
spec332CopyBrowserScreenshot('step-1-backup-selected', 'step-1-backup-selected.png');
|
||||
});
|
||||
|
||||
it('captures step 2 screenshots for mapping summary + resolver expansion', function (): void {
|
||||
[$user, $tenant] = spec332ScreenshotsTenant();
|
||||
$backupSet = spec332ScreenshotsUnresolvedGroupBackupFixture($tenant);
|
||||
|
||||
EntraGroup::factory()->for($tenant)->create([
|
||||
'entra_id' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
'display_name' => 'Spec332 Cached Target Group',
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$page = visit(spec332RestoreWizardScreenshotsLoginUrl($user, $tenant, spec332ScreenshotsRedirect($tenant)));
|
||||
|
||||
$page->resize(1920, 1200)
|
||||
->waitForText('Select Backup Set');
|
||||
|
||||
spec332ScreenshotsSelectBackupSet($page, $backupSet);
|
||||
|
||||
$page->waitForText('Resolve 1 remaining group mapping before validation can prove the current draft.');
|
||||
spec332ScreenshotsWizardNext($page);
|
||||
|
||||
$page->waitForText('Resolve target mappings');
|
||||
$page->script('window.scrollTo(0, 0);');
|
||||
$page->screenshot(true, spec332RestoreWizardScreenshot('step-2-scope-default'));
|
||||
spec332CopyBrowserScreenshot('step-2-scope-default', 'step-2-scope-default.png');
|
||||
|
||||
$page->click('[data-testid="restore-run-open-mapping-resolver"]')
|
||||
->waitForText('Resolve mappings');
|
||||
|
||||
$page->script('window.scrollTo(0, 0);');
|
||||
$page->screenshot(true, spec332RestoreWizardScreenshot('step-2-resolver-expanded'));
|
||||
spec332CopyBrowserScreenshot('step-2-resolver-expanded', 'step-2-resolver-expanded.png');
|
||||
});
|
||||
|
||||
it('captures step 3 screenshot when validation is blocked', function (): void {
|
||||
[$user, $tenant] = spec332ScreenshotsTenant();
|
||||
$backupSet = spec332ScreenshotsMetadataOnlyFixture($tenant);
|
||||
|
||||
$page = visit(spec332RestoreWizardScreenshotsLoginUrl($user, $tenant, spec332ScreenshotsRedirect($tenant)));
|
||||
|
||||
$page->resize(1920, 1200)
|
||||
->waitForText('Select Backup Set');
|
||||
|
||||
spec332ScreenshotsSelectBackupSet($page, $backupSet);
|
||||
|
||||
$page->waitForText('The selected backup does not contain a usable captured item yet.');
|
||||
spec332ScreenshotsWizardNext($page);
|
||||
$page->waitForText('Define Restore Scope');
|
||||
spec332ScreenshotsWizardNext($page);
|
||||
$page->waitForText('Safety & Conflict Checks');
|
||||
|
||||
$page->waitForText('Run checks')
|
||||
->click('Run checks')
|
||||
->waitForText('Snapshot completeness');
|
||||
|
||||
spec332ScreenshotsWizardNext($page);
|
||||
$page->waitForText('Validation blocked');
|
||||
|
||||
$page->script('window.scrollTo(0, 0);');
|
||||
$page->screenshot(true, spec332RestoreWizardScreenshot('step-3-validation-blocked'));
|
||||
spec332CopyBrowserScreenshot('step-3-validation-blocked', 'step-3-validation-blocked.png');
|
||||
});
|
||||
|
||||
it('captures step 4 and 5 screenshots after preview generation', function (): void {
|
||||
[$user, $tenant] = spec332ScreenshotsTenant();
|
||||
$backupSet = spec332ScreenshotsUsableBackupFixture($tenant);
|
||||
|
||||
$page = visit(spec332RestoreWizardScreenshotsLoginUrl($user, $tenant, spec332ScreenshotsRedirect($tenant)));
|
||||
|
||||
$page->resize(1920, 1200)
|
||||
->waitForText('Select Backup Set');
|
||||
|
||||
spec332ScreenshotsSelectBackupSet($page, $backupSet);
|
||||
|
||||
$page->waitForText('A usable source backup is selected for this restore draft.');
|
||||
spec332ScreenshotsWizardNext($page);
|
||||
$page->waitForText('Define Restore Scope');
|
||||
spec332ScreenshotsWizardNext($page);
|
||||
$page->waitForText('Safety & Conflict Checks');
|
||||
|
||||
$page->waitForText('Run checks')
|
||||
->click('Run checks')
|
||||
->waitForText('No group-based assignments detected.');
|
||||
|
||||
spec332ScreenshotsWizardNext($page);
|
||||
|
||||
$page->waitForText('Generate preview')
|
||||
->click('Generate preview')
|
||||
->waitForText('Policy change preview');
|
||||
|
||||
$page->script('window.scrollTo(0, 0);');
|
||||
$page->screenshot(true, spec332RestoreWizardScreenshot('step-4-preview-generated'));
|
||||
spec332CopyBrowserScreenshot('step-4-preview-generated', 'step-4-preview-generated.png');
|
||||
|
||||
spec332ScreenshotsWizardNext($page);
|
||||
$page->waitForText('Confirm & Execute');
|
||||
|
||||
$page->script('window.scrollTo(0, 0);');
|
||||
$page->screenshot(true, spec332RestoreWizardScreenshot('step-5-confirm-ready'));
|
||||
spec332CopyBrowserScreenshot('step-5-confirm-ready', 'step-5-confirm-ready.png');
|
||||
});
|
||||
@ -0,0 +1,605 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
pest()->browser()->timeout(30_000);
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function spec332RestoreWizardSmokeLoginUrl(User $user, ManagedEnvironment $tenant, string $redirect = ''): string
|
||||
{
|
||||
return route('admin.local.smoke-login', array_filter([
|
||||
'email' => $user->email,
|
||||
'tenant' => $tenant->external_id,
|
||||
'workspace' => $tenant->workspace->slug,
|
||||
'redirect' => $redirect,
|
||||
], static fn (?string $value): bool => filled($value)));
|
||||
}
|
||||
|
||||
function spec332BrowserTenant(): array
|
||||
{
|
||||
$tenant = ManagedEnvironment::factory()->create([
|
||||
'rbac_status' => 'ok',
|
||||
'rbac_last_checked_at' => now(),
|
||||
]);
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
ensureDefaultProviderConnection($tenant, 'microsoft');
|
||||
bindFailHardGraphClient();
|
||||
|
||||
return [$user, $tenant];
|
||||
}
|
||||
|
||||
function spec332BrowserRedirect(ManagedEnvironment $tenant): string
|
||||
{
|
||||
$redirectBase = RestoreRunResource::getUrl('create', panel: 'admin', tenant: $tenant);
|
||||
|
||||
return parse_url($redirectBase, PHP_URL_PATH) ?: '/admin';
|
||||
}
|
||||
|
||||
function spec332BrowserSelectBackupSet($page, BackupSet $backupSet): void
|
||||
{
|
||||
$selected = $page->script(<<<JS
|
||||
(() => {
|
||||
const select = document.getElementById('form.backup_set_id');
|
||||
|
||||
if (! select) {
|
||||
return false;
|
||||
}
|
||||
|
||||
select.value = '{$backupSet->getKey()}';
|
||||
select.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
return true;
|
||||
})()
|
||||
JS);
|
||||
|
||||
expect($selected)->toBeTrue();
|
||||
}
|
||||
|
||||
function spec332BrowserElementIsVisibleScript(string $testId): string
|
||||
{
|
||||
return <<<JS
|
||||
(() => {
|
||||
const element = document.querySelector('[data-testid="{$testId}"]');
|
||||
|
||||
if (! element) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(element);
|
||||
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& ! element.hidden
|
||||
&& Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
|
||||
})()
|
||||
JS;
|
||||
}
|
||||
|
||||
function spec332BrowserWizardNext($page): void
|
||||
{
|
||||
$clicked = $page->script(<<<'JS'
|
||||
(() => {
|
||||
const footer = document.querySelector('.fi-sc-wizard-footer');
|
||||
|
||||
if (! footer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextTrigger = footer.querySelector('div[x-on\\:click*="requestNextStep"]');
|
||||
|
||||
if (! nextTrigger) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nextTrigger.classList.contains('fi-hidden')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
nextTrigger.scrollIntoView({ block: 'center' });
|
||||
nextTrigger.click();
|
||||
|
||||
return true;
|
||||
})()
|
||||
JS);
|
||||
|
||||
expect($clicked)->toBeTrue();
|
||||
}
|
||||
|
||||
function spec332BrowserUsableBackupFixture(ManagedEnvironment $tenant): array
|
||||
{
|
||||
$policy = Policy::create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'spec332-browser-policy-usable',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Spec332 Browser Policy',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
PolicyVersion::create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'captured_at' => now(),
|
||||
'snapshot' => [
|
||||
'foo' => 'current',
|
||||
],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Spec332 Browser Usable Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_identifier' => $policy->external_id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'captured_at' => now(),
|
||||
'payload' => [
|
||||
'foo' => 'backup',
|
||||
'displayName' => 'Spec332 Browser Policy',
|
||||
],
|
||||
'assignments' => [],
|
||||
'metadata' => [
|
||||
'displayName' => 'Spec332 Browser Policy',
|
||||
],
|
||||
]);
|
||||
|
||||
return [$backupSet, $backupItem];
|
||||
}
|
||||
|
||||
function spec332BrowserUnresolvedGroupFixture(ManagedEnvironment $tenant): array
|
||||
{
|
||||
$policy = Policy::create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'spec332-browser-policy-group',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Spec332 Group Mapping Policy',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Spec332 Browser Group Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_identifier' => $policy->external_id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'captured_at' => now(),
|
||||
'payload' => [
|
||||
'foo' => 'backup',
|
||||
],
|
||||
'assignments' => [[
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => '11111111-1111-1111-1111-111111111111',
|
||||
'group_display_name' => 'Spec332 Missing Group',
|
||||
],
|
||||
]],
|
||||
'metadata' => [
|
||||
'displayName' => 'Spec332 Group Mapping Policy',
|
||||
],
|
||||
]);
|
||||
|
||||
return [$backupSet, $backupItem];
|
||||
}
|
||||
|
||||
function spec332BrowserMetadataOnlyFixture(ManagedEnvironment $tenant): array
|
||||
{
|
||||
$policy = Policy::create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'spec332-browser-policy-metadata-only',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Spec332 Metadata Only Browser Policy',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Spec332 Browser Metadata-only Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_identifier' => $policy->external_id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'captured_at' => now(),
|
||||
'payload' => [],
|
||||
'assignments' => [],
|
||||
'metadata' => [
|
||||
'displayName' => 'Spec332 Metadata Only Browser Policy',
|
||||
'snapshot_source' => 'metadata_only',
|
||||
'warnings' => ['metadata only fallback'],
|
||||
],
|
||||
]);
|
||||
|
||||
return [$backupSet, $backupItem];
|
||||
}
|
||||
|
||||
it('shows the full product process flow on step 1', function (): void {
|
||||
[$user, $tenant] = spec332BrowserTenant();
|
||||
[$backupSet] = spec332BrowserUsableBackupFixture($tenant);
|
||||
|
||||
$page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant)));
|
||||
|
||||
$page->resize(1920, 1200)
|
||||
->waitForText('Select Backup Set');
|
||||
|
||||
spec332BrowserSelectBackupSet($page, $backupSet);
|
||||
|
||||
$page->waitForText('A usable source backup is selected for this restore draft.')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->assertSee('Restore Safety')
|
||||
->assertSee('Backup quality summary')
|
||||
->assertSee('Restore safety gates')
|
||||
->assertSee('Restore Proof')
|
||||
->assertSee('Diagnostics - Collapsed')
|
||||
->assertSee('Continue to scope and resolve required mappings.')
|
||||
->assertSee('Validate impact before execution.')
|
||||
->assertDontSee('Technical startability')
|
||||
->assertDontSee('write-gate')
|
||||
->assertDontSee('hard-blocker')
|
||||
->assertDontSee('Is this dangerous?')
|
||||
->assertDontSee('tenant-wide recoverability')
|
||||
->assertScript(spec332BrowserElementIsVisibleScript('restore-run-process-flow-full'), true)
|
||||
->assertScript(spec332BrowserElementIsVisibleScript('restore-run-process-flow-compact'), false)
|
||||
->assertScript('document.querySelector("[data-testid=\"restore-run-diagnostics-disclosure\"]")?.open === false', true)
|
||||
->assertSee('A usable source backup is selected for this restore draft.');
|
||||
});
|
||||
|
||||
it('shows compact restore safety status by default on step 2', function (): void {
|
||||
[$user, $tenant] = spec332BrowserTenant();
|
||||
[$backupSet] = spec332BrowserUsableBackupFixture($tenant);
|
||||
|
||||
$page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant)));
|
||||
|
||||
$page->resize(1920, 1200)
|
||||
->waitForText('Select Backup Set');
|
||||
|
||||
spec332BrowserSelectBackupSet($page, $backupSet);
|
||||
|
||||
$page->waitForText('A usable source backup is selected for this restore draft.');
|
||||
spec332BrowserWizardNext($page);
|
||||
|
||||
$page->waitForText('2/7 gates complete')
|
||||
->assertSee('Restore safety status')
|
||||
->assertSee('2/7 gates complete')
|
||||
->assertSee('View safety gates')
|
||||
->assertDontSee('Hide safety gates')
|
||||
->assertSee('Restore Proof')
|
||||
->assertSee('Diagnostics - Collapsed')
|
||||
->assertDontSee('Technical startability')
|
||||
->assertDontSee('write-gate')
|
||||
->assertDontSee('hard-blocker')
|
||||
->assertScript(spec332BrowserElementIsVisibleScript('restore-run-process-flow-compact'), true)
|
||||
->assertScript(spec332BrowserElementIsVisibleScript('restore-run-process-flow-full'), false)
|
||||
->assertScript('document.querySelector("[data-testid=\"restore-run-process-flow-compact-expanded\"]") === null', true)
|
||||
->assertScript('document.querySelector("[data-testid=\"restore-run-diagnostics-disclosure\"]")?.open === false', true);
|
||||
});
|
||||
|
||||
it('keeps group mapping details collapsed until explicitly opened on step 2', function (): void {
|
||||
[$user, $tenant] = spec332BrowserTenant();
|
||||
[$backupSet] = spec332BrowserUnresolvedGroupFixture($tenant);
|
||||
|
||||
$page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant)));
|
||||
|
||||
$page->resize(1920, 1200)
|
||||
->waitForText('Select Backup Set');
|
||||
|
||||
spec332BrowserSelectBackupSet($page, $backupSet);
|
||||
|
||||
$page->waitForText('Resolve 1 remaining group mapping before validation can prove the current draft.');
|
||||
spec332BrowserWizardNext($page);
|
||||
|
||||
$page->waitForText('Resolve target mappings')
|
||||
->assertSee('Scope summary')
|
||||
->assertSee('Resolve mappings')
|
||||
->assertSee('0 of 1 mappings resolved')
|
||||
->assertSee('Resolve target mappings')
|
||||
->assertSee('Restore safety status')
|
||||
->assertSee('Restore Proof')
|
||||
->assertSee('Diagnostics - Collapsed')
|
||||
->assertSee('Resolve required mappings before validation can run.')
|
||||
->assertDontSee('Paste the target Entra ID group Object ID (GUID).')
|
||||
->assertScript(<<<'JS'
|
||||
(() => {
|
||||
const section = Array.from(document.querySelectorAll('.fi-section')).find((element) =>
|
||||
element.textContent?.includes('Resolve target mappings')
|
||||
);
|
||||
|
||||
return section?.querySelector('.fi-section-content-ctn')?.getAttribute('aria-expanded') === 'false';
|
||||
})()
|
||||
JS, true)
|
||||
->assertScript('document.querySelector("[data-testid=\"restore-run-process-flow-compact-expanded\"]") === null', true);
|
||||
|
||||
$page->click('[data-testid="restore-run-open-mapping-resolver"]');
|
||||
|
||||
$page->waitForText('0 of 1 mappings resolved')
|
||||
->assertSee('0 of 1 mappings resolved')
|
||||
->assertSee('1 unresolved')
|
||||
->assertSee('0 skipped')
|
||||
->assertSee('Resolve required mappings before validation can run.')
|
||||
->assertSee('Select a target group from the directory cache or enter a target group object ID as a fallback. Required mappings must be resolved before validation can run.')
|
||||
->assertSee('Hide mapping details')
|
||||
->assertDontSee('Return to scope summary')
|
||||
->assertDontSee('Paste the target Entra ID group Object ID (GUID).')
|
||||
->assertScript(<<<'JS'
|
||||
(() => {
|
||||
const section = Array.from(document.querySelectorAll('.fi-section')).find((element) =>
|
||||
element.textContent?.includes('Resolve target mappings')
|
||||
);
|
||||
|
||||
return section?.querySelector('.fi-section-content-ctn')?.getAttribute('aria-expanded') === 'true';
|
||||
})()
|
||||
JS, true);
|
||||
});
|
||||
|
||||
it('shows a task-specific empty picker when the directory group cache is unavailable on step 2', function (): void {
|
||||
[$user, $tenant] = spec332BrowserTenant();
|
||||
[$backupSet] = spec332BrowserUnresolvedGroupFixture($tenant);
|
||||
|
||||
$page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant)));
|
||||
|
||||
$page->resize(1920, 1200)
|
||||
->waitForText('Select Backup Set');
|
||||
|
||||
spec332BrowserSelectBackupSet($page, $backupSet);
|
||||
|
||||
$page->waitForText('Resolve 1 remaining group mapping before validation can prove the current draft.');
|
||||
spec332BrowserWizardNext($page);
|
||||
|
||||
$page->waitForText('Resolve target mappings');
|
||||
|
||||
$pickerOpened = $page->script(<<<'JS'
|
||||
(() => {
|
||||
const header = Array.from(document.querySelectorAll('.fi-section-header')).find((element) =>
|
||||
element.textContent?.includes('Resolve target mappings')
|
||||
);
|
||||
|
||||
header?.click();
|
||||
|
||||
const pickerButton = document.querySelector('button[wire\\:click*="select_from_directory_cache_11111111_1111_1111_1111_111111111111"]');
|
||||
|
||||
pickerButton?.click();
|
||||
|
||||
return Boolean(pickerButton);
|
||||
})()
|
||||
JS);
|
||||
|
||||
expect($pickerOpened)->toBeTrue();
|
||||
|
||||
$page->waitForText('Resolve target group mapping')
|
||||
->assertSee('Source group')
|
||||
->assertSee('Spec332 Missing Group')
|
||||
->assertSee('Source ID: 11111111-1111-1111-1111-111111111111')
|
||||
->assertSee('No directory group cache available')
|
||||
->assertSee('TenantPilot needs cached directory groups before target mappings can be selected.')
|
||||
->assertSee('Sync directory groups, then return to this mapping.')
|
||||
->assertSee('Open group sync')
|
||||
->assertSee('View group sync operations')
|
||||
->assertScript(<<<'JS'
|
||||
(() => {
|
||||
const modalContent = document.querySelector('[data-testid="restore-group-picker-modal-content"]');
|
||||
|
||||
if (! modalContent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const actionLabels = Array.from(modalContent.querySelectorAll('a, button'))
|
||||
.map((element) => element.textContent?.trim() ?? '')
|
||||
.filter(Boolean);
|
||||
|
||||
return ! actionLabels.includes('Directory Groups')
|
||||
&& ! actionLabels.includes('Operations')
|
||||
&& ! modalContent.textContent?.includes('No cached groups found')
|
||||
&& ! modalContent.textContent?.includes('No groups found in tenant')
|
||||
&& ! modalContent.textContent?.includes('Search groups…');
|
||||
})()
|
||||
JS, true);
|
||||
});
|
||||
|
||||
it('shows cached directory group results in the picker when cache exists on step 2', function (): void {
|
||||
[$user, $tenant] = spec332BrowserTenant();
|
||||
[$backupSet] = spec332BrowserUnresolvedGroupFixture($tenant);
|
||||
|
||||
EntraGroup::factory()->for($tenant)->create([
|
||||
'entra_id' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
'display_name' => 'Spec332 Cached Target Group',
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant)));
|
||||
|
||||
$page->resize(1920, 1200)
|
||||
->waitForText('Select Backup Set');
|
||||
|
||||
spec332BrowserSelectBackupSet($page, $backupSet);
|
||||
|
||||
$page->waitForText('Resolve 1 remaining group mapping before validation can prove the current draft.');
|
||||
spec332BrowserWizardNext($page);
|
||||
|
||||
$page->waitForText('Resolve target mappings')
|
||||
->click('[data-testid="restore-run-open-mapping-resolver"]');
|
||||
|
||||
$pickerOpened = $page->script(<<<'JS'
|
||||
(() => {
|
||||
const pickerButton = document.querySelector('button[wire\\:click*="select_from_directory_cache_11111111_1111_1111_1111_111111111111"]');
|
||||
|
||||
pickerButton?.click();
|
||||
|
||||
return Boolean(pickerButton);
|
||||
})()
|
||||
JS);
|
||||
|
||||
expect($pickerOpened)->toBeTrue();
|
||||
|
||||
$page->waitForText('Resolve target group mapping')
|
||||
->assertSee('Spec332 Cached Target Group')
|
||||
->assertDontSee('No directory group cache available');
|
||||
});
|
||||
|
||||
it('blocks next on step 2 when required mappings are unresolved', function (): void {
|
||||
[$user, $tenant] = spec332BrowserTenant();
|
||||
[$backupSet] = spec332BrowserUnresolvedGroupFixture($tenant);
|
||||
|
||||
$page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant)));
|
||||
|
||||
$page->resize(1920, 1200)
|
||||
->waitForText('Select Backup Set');
|
||||
|
||||
spec332BrowserSelectBackupSet($page, $backupSet);
|
||||
|
||||
$page->waitForText('Resolve 1 remaining group mapping before validation can prove the current draft.');
|
||||
spec332BrowserWizardNext($page);
|
||||
|
||||
$page->waitForText('Resolve target mappings');
|
||||
spec332BrowserWizardNext($page);
|
||||
|
||||
$page->waitForText('Mappings required')
|
||||
->assertDontSee('field is required.')
|
||||
->assertSee('Resolve required mappings before validation can run.')
|
||||
->assertSee('Define Restore Scope');
|
||||
});
|
||||
|
||||
it('blocks next on step 3 when validation has blockers', function (): void {
|
||||
[$user, $tenant] = spec332BrowserTenant();
|
||||
[$backupSet] = spec332BrowserMetadataOnlyFixture($tenant);
|
||||
|
||||
$page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant)));
|
||||
|
||||
$page->resize(1920, 1200)
|
||||
->waitForText('Select Backup Set');
|
||||
|
||||
spec332BrowserSelectBackupSet($page, $backupSet);
|
||||
|
||||
$page->waitForText('The selected backup does not contain a usable captured item yet.');
|
||||
spec332BrowserWizardNext($page);
|
||||
$page->waitForText('Define Restore Scope');
|
||||
spec332BrowserWizardNext($page);
|
||||
$page->waitForText('Safety & Conflict Checks');
|
||||
|
||||
$page->waitForText('Run checks')
|
||||
->click('Run checks')
|
||||
->waitForText('Snapshot completeness');
|
||||
|
||||
spec332BrowserWizardNext($page);
|
||||
|
||||
$page
|
||||
->waitForText('Validation blocked')
|
||||
->assertSee('Resolve the blocking validation issues before moving to preview.')
|
||||
->assertSee('Safety & Conflict Checks');
|
||||
});
|
||||
|
||||
it('keeps preview decision-first on step 4 while safety gates stay compact', function (): void {
|
||||
[$user, $tenant] = spec332BrowserTenant();
|
||||
[$backupSet] = spec332BrowserUsableBackupFixture($tenant);
|
||||
|
||||
$page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant)));
|
||||
|
||||
$page->resize(1920, 1200)
|
||||
->waitForText('Select Backup Set');
|
||||
|
||||
spec332BrowserSelectBackupSet($page, $backupSet);
|
||||
|
||||
$page->waitForText('A usable source backup is selected for this restore draft.');
|
||||
spec332BrowserWizardNext($page);
|
||||
$page->waitForText('Define Restore Scope');
|
||||
spec332BrowserWizardNext($page);
|
||||
$page->waitForText('Safety & Conflict Checks');
|
||||
|
||||
$page->waitForText('Run checks')
|
||||
->click('Run checks')
|
||||
->waitForText('No group-based assignments detected.');
|
||||
|
||||
spec332BrowserWizardNext($page);
|
||||
|
||||
$page->waitForText('Generate preview')
|
||||
->click('Generate preview')
|
||||
->waitForText('Policy change preview')
|
||||
->assertSee('Review the preview and complete confirmation before execution can be queued.')
|
||||
->assertSee('Restore safety status')
|
||||
->assertSee('View safety gates')
|
||||
->assertDontSee('Hide safety gates')
|
||||
->assertSee('Restore Proof')
|
||||
->assertSee('Operation proof')
|
||||
->assertSee('Diagnostics - Collapsed')
|
||||
->assertDontSee('tenant-wide recovery is proven')
|
||||
->assertScript('document.querySelector("[data-testid=\"restore-run-process-flow-compact\"]") !== null', true)
|
||||
->assertScript('document.querySelector("[data-testid=\"restore-run-process-flow-full\"]") === null', true)
|
||||
->assertScript('document.querySelector("[data-testid=\"restore-run-diagnostics-disclosure\"]")?.open === false', true);
|
||||
});
|
||||
|
||||
it('shows confirm step readiness after preview and checks are current', function (): void {
|
||||
[$user, $tenant] = spec332BrowserTenant();
|
||||
[$backupSet] = spec332BrowserUsableBackupFixture($tenant);
|
||||
|
||||
$page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant)));
|
||||
|
||||
$page->resize(1920, 1200)
|
||||
->waitForText('Select Backup Set');
|
||||
|
||||
spec332BrowserSelectBackupSet($page, $backupSet);
|
||||
|
||||
$page->waitForText('A usable source backup is selected for this restore draft.');
|
||||
spec332BrowserWizardNext($page);
|
||||
$page->waitForText('Define Restore Scope');
|
||||
spec332BrowserWizardNext($page);
|
||||
$page->waitForText('Safety & Conflict Checks');
|
||||
|
||||
$page->waitForText('Run checks')
|
||||
->click('Run checks')
|
||||
->waitForText('No group-based assignments detected.');
|
||||
|
||||
spec332BrowserWizardNext($page);
|
||||
|
||||
$page->waitForText('Generate preview')
|
||||
->click('Generate preview')
|
||||
->waitForText('Policy change preview');
|
||||
|
||||
spec332BrowserWizardNext($page);
|
||||
|
||||
$page
|
||||
->waitForText('Confirm & Execute')
|
||||
->assertSee('Confirmation summary')
|
||||
->assertSee('Available after confirmation')
|
||||
->assertSee('Confirmation does not claim recovery.')
|
||||
->assertSee('Restore Proof')
|
||||
->assertSee('Operation proof')
|
||||
->assertSee('Post-run evidence')
|
||||
->assertDontSee('tenant-wide recovery is proven');
|
||||
});
|
||||
@ -20,10 +20,9 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Livewire;
|
||||
@ -125,7 +124,7 @@ function makeHealthyBackupForRecoveryKpi(\App\Models\ManagedEnvironment $tenant,
|
||||
]),
|
||||
'Weakened',
|
||||
'The restore did not complete successfully. Follow-up is still required.',
|
||||
'ManagedEnvironment recovery is not proven.',
|
||||
'Target environment recovery is not proven.',
|
||||
RestoreResultAttention::STATE_FAILED,
|
||||
],
|
||||
'partial history' => [
|
||||
@ -138,7 +137,7 @@ function makeHealthyBackupForRecoveryKpi(\App\Models\ManagedEnvironment $tenant,
|
||||
]),
|
||||
'Weakened',
|
||||
'The restore reached a terminal state, but some items or assignments still need follow-up.',
|
||||
'ManagedEnvironment-wide recovery is not proven.',
|
||||
'Target environment recovery is not proven.',
|
||||
RestoreResultAttention::STATE_PARTIAL,
|
||||
],
|
||||
'follow-up history' => [
|
||||
@ -151,7 +150,7 @@ function makeHealthyBackupForRecoveryKpi(\App\Models\ManagedEnvironment $tenant,
|
||||
]),
|
||||
'Weakened',
|
||||
'The restore completed, but follow-up remains for skipped or non-applied work.',
|
||||
'ManagedEnvironment-wide recovery is not proven.',
|
||||
'Target environment recovery is not proven.',
|
||||
RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
|
||||
],
|
||||
'calm completed history' => [
|
||||
@ -164,7 +163,7 @@ function makeHealthyBackupForRecoveryKpi(\App\Models\ManagedEnvironment $tenant,
|
||||
]),
|
||||
'No recent issues visible',
|
||||
'Recent executed restore history exists without a current follow-up signal.',
|
||||
'ManagedEnvironment-wide recovery is not proven.',
|
||||
'Target environment recovery is not proven.',
|
||||
'no_recent_issues_visible',
|
||||
],
|
||||
]);
|
||||
@ -249,12 +248,12 @@ function makeHealthyBackupForRecoveryKpi(\App\Models\ManagedEnvironment $tenant,
|
||||
]);
|
||||
|
||||
expect($stats['High severity findings'])->toMatchArray([
|
||||
'value' => '2',
|
||||
'url' => FindingResource::getUrl('index', [
|
||||
'tab' => 'needs_action',
|
||||
'high_severity' => 1,
|
||||
], panel: 'admin', tenant: $tenant),
|
||||
])
|
||||
'value' => '2',
|
||||
'url' => FindingResource::getUrl('index', [
|
||||
'tab' => 'needs_action',
|
||||
'high_severity' => 1,
|
||||
], panel: 'admin', tenant: $tenant),
|
||||
])
|
||||
->and($stats['Overdue findings']['value'])->toBe('0')
|
||||
->and($stats['Overdue findings']['url'])->toBe(FindingResource::getUrl('index', [
|
||||
'tab' => 'overdue',
|
||||
@ -435,7 +434,7 @@ function makeHealthyBackupForRecoveryKpi(\App\Models\ManagedEnvironment $tenant,
|
||||
|
||||
expect($recoveryStat['description'])
|
||||
->toContain('No executed restore history is visible in the latest tenant restore records.')
|
||||
->toContain('ManagedEnvironment-wide recovery is not proven.');
|
||||
->toContain('Target environment recovery is not proven.');
|
||||
});
|
||||
|
||||
it('surfaces weak and calm restore history on the recovery evidence KPI', function (
|
||||
|
||||
@ -23,10 +23,9 @@
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@ -707,7 +706,7 @@ function makeHealthyBackupForRecoveryNeedsAttention(\App\Models\ManagedEnvironme
|
||||
->assertSee('Backups are recent and healthy')
|
||||
->assertSee('No recent restore issues visible')
|
||||
->assertSee('Recent executed restore history exists without a current follow-up signal.')
|
||||
->assertSee('ManagedEnvironment-wide recovery is not proven.')
|
||||
->assertSee('Target environment recovery is not proven.')
|
||||
->assertDontSee('Recovery evidence is unvalidated')
|
||||
->assertDontSee('Recent restore failed');
|
||||
});
|
||||
|
||||
@ -106,7 +106,7 @@
|
||||
->assertSee('Follow-up required')
|
||||
->assertSee('Review skipped or non-applied items before closing the run.')
|
||||
->assertSee('No dominant cause recorded')
|
||||
->assertSee('ManagedEnvironment-wide recovery is not proven.')
|
||||
->assertSee('Target environment recovery is not proven.')
|
||||
->assertDontSee('review_skipped_items')
|
||||
->assertDontSee('run_completed_not_recovery_proven');
|
||||
});
|
||||
|
||||
@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('shows confirmation guidance when preview and checks are complete', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create([
|
||||
'rbac_status' => 'ok',
|
||||
'rbac_last_checked_at' => now(),
|
||||
]);
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
ensureDefaultProviderConnection($tenant, 'microsoft');
|
||||
|
||||
/** @var RestoreSafetyResolver $resolver */
|
||||
$resolver = app(RestoreSafetyResolver::class);
|
||||
|
||||
$data = [
|
||||
'backup_set_id' => 10,
|
||||
'scope_mode' => 'selected',
|
||||
'backup_item_ids' => [1],
|
||||
'group_mapping' => [],
|
||||
'check_summary' => ['blocking' => 0, 'warning' => 0, 'safe' => 1],
|
||||
'check_results' => [['code' => 'safe', 'severity' => 'safe']],
|
||||
'checks_ran_at' => now('UTC')->toIso8601String(),
|
||||
'preview_summary' => [
|
||||
'generated_at' => now('UTC')->toIso8601String(),
|
||||
'policies_total' => 1,
|
||||
'policies_changed' => 0,
|
||||
'assignments_changed' => 0,
|
||||
'scope_tags_changed' => 0,
|
||||
],
|
||||
'preview_diffs' => [[
|
||||
'policy_identifier' => 'policy-1',
|
||||
'display_name' => 'Bitlocker Require',
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'platform' => 'all',
|
||||
'action' => 'update',
|
||||
'assignments_changed' => false,
|
||||
'scope_tags_changed' => false,
|
||||
'diff' => [
|
||||
'summary' => ['added' => 0, 'removed' => 0, 'changed' => 0],
|
||||
'changed' => [],
|
||||
'added' => [],
|
||||
'removed' => [],
|
||||
],
|
||||
]],
|
||||
'preview_ran_at' => now('UTC')->toIso8601String(),
|
||||
];
|
||||
|
||||
$data['check_basis'] = $resolver->checksBasisFromData($data);
|
||||
$data['preview_basis'] = $resolver->previewBasisFromData($data);
|
||||
$data = App\Filament\Resources\RestoreRunResource::synchronizeRestoreSafetyDraft($data);
|
||||
|
||||
setAdminPanelContext($tenant);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(CreateRestoreRun::class)
|
||||
->set('data', $data)
|
||||
->goToWizardStep(4)
|
||||
->assertSeeText('Review the preview and complete confirmation before execution can be queued.')
|
||||
->assertDontSeeText('Review prerequisites before execution.')
|
||||
->assertSeeText('Policy change preview')
|
||||
->assertSeeText('BitLocker Require')
|
||||
->assertSeeText('No policy changes')
|
||||
->assertSeeText('1 policy reviewed')
|
||||
->assertDontSeeText('deviceCompliancePolicy')
|
||||
->assertDontSeeText('deviceCompliancePolicy • all');
|
||||
});
|
||||
@ -39,7 +39,7 @@ function makeAssignment(string $odataType, string $groupId, ?string $displayName
|
||||
->assertSee('Select Backup Set');
|
||||
});
|
||||
|
||||
test('restore wizard group mapping renders DB-only with manual GUID UX', function () {
|
||||
test('restore wizard group mapping renders DB-only with resolver-mode UX', function () {
|
||||
$tenant = ManagedEnvironment::factory()->create();
|
||||
[$user] = createUserWithTenant($tenant);
|
||||
|
||||
@ -49,7 +49,6 @@ function makeAssignment(string $odataType, string $groupId, ?string $displayName
|
||||
]);
|
||||
|
||||
$groupId = '11111111-2222-3333-4444-555555555555';
|
||||
$expectedMasked = '…'.substr($groupId, -8);
|
||||
|
||||
BackupItem::factory()->create([
|
||||
'managed_environment_id' => $tenant->getKey(),
|
||||
@ -72,7 +71,10 @@ function makeAssignment(string $odataType, string $groupId, ?string $displayName
|
||||
])
|
||||
->get($url)
|
||||
->assertOk()
|
||||
->assertSee($expectedMasked)
|
||||
->assertSee('Paste the target Entra ID group Object ID')
|
||||
->assertSee('Use SKIP to omit the assignment.');
|
||||
->assertSee('Example Group')
|
||||
->assertSee('Source ID: '.$groupId)
|
||||
->assertSee('Resolve target mappings')
|
||||
->assertSee('Select a target group from the directory cache or enter a target group object ID as a fallback.')
|
||||
->assertDontSee('Paste the target Entra ID group Object ID')
|
||||
->assertDontSee('Use SKIP to omit the assignment.');
|
||||
});
|
||||
|
||||
@ -0,0 +1,556 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\Policy;
|
||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function spec332ProductProcessFlowTenant(): array
|
||||
{
|
||||
$tenant = ManagedEnvironment::factory()->create([
|
||||
'rbac_status' => 'ok',
|
||||
'rbac_last_checked_at' => now(),
|
||||
]);
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
ensureDefaultProviderConnection($tenant, 'microsoft');
|
||||
|
||||
return [$user, $tenant];
|
||||
}
|
||||
|
||||
function spec332UsableBackupFixture(ManagedEnvironment $tenant): array
|
||||
{
|
||||
$policy = Policy::create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'spec332-policy-usable',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Spec332 Device Policy',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Spec332 Usable Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_identifier' => $policy->external_id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'payload' => [
|
||||
'displayName' => 'Spec332 Device Policy',
|
||||
'settings' => ['foo' => 'bar'],
|
||||
],
|
||||
'metadata' => [
|
||||
'displayName' => 'Spec332 Device Policy',
|
||||
],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
return [$backupSet, $backupItem];
|
||||
}
|
||||
|
||||
function spec332EmptyBackupFixture(ManagedEnvironment $tenant): BackupSet
|
||||
{
|
||||
return BackupSet::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Spec332 Empty Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
function spec332MetadataOnlyBackupFixture(ManagedEnvironment $tenant): array
|
||||
{
|
||||
$policy = Policy::create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'spec332-policy-metadata-only',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Spec332 Metadata Only Policy',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Spec332 Metadata-only Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_identifier' => $policy->external_id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'payload' => [],
|
||||
'metadata' => [
|
||||
'displayName' => 'Spec332 Metadata Only Policy',
|
||||
'snapshot_source' => 'metadata_only',
|
||||
'warnings' => ['metadata only fallback'],
|
||||
],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
return [$backupSet, $backupItem];
|
||||
}
|
||||
|
||||
function spec332UnresolvedGroupBackupFixture(ManagedEnvironment $tenant): array
|
||||
{
|
||||
$policy = Policy::create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'spec332-policy-group',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Spec332 Group Mapping Policy',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Spec332 Group Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_identifier' => $policy->external_id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'payload' => [
|
||||
'displayName' => 'Spec332 Group Mapping Policy',
|
||||
],
|
||||
'metadata' => [
|
||||
'displayName' => 'Spec332 Group Mapping Policy',
|
||||
],
|
||||
'assignments' => [[
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => '11111111-1111-1111-1111-111111111111',
|
||||
'group_display_name' => 'Spec332 Missing Group',
|
||||
],
|
||||
]],
|
||||
]);
|
||||
|
||||
return [$backupSet, $backupItem];
|
||||
}
|
||||
|
||||
function spec332WizardComponent($user, ManagedEnvironment $tenant): \Livewire\Features\SupportTesting\Testable
|
||||
{
|
||||
setAdminPanelContext($tenant);
|
||||
|
||||
return Livewire::actingAs($user)->test(CreateRestoreRun::class);
|
||||
}
|
||||
|
||||
function spec332CurrentPreviewData(BackupSet $backupSet, BackupItem $backupItem): array
|
||||
{
|
||||
/** @var RestoreSafetyResolver $resolver */
|
||||
$resolver = app(RestoreSafetyResolver::class);
|
||||
|
||||
$data = [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'scope_mode' => 'selected',
|
||||
'backup_item_ids' => [(int) $backupItem->getKey()],
|
||||
'group_mapping' => [],
|
||||
'check_summary' => ['blocking' => 0, 'warning' => 0, 'safe' => 1],
|
||||
'check_results' => [['code' => 'safe', 'severity' => 'safe']],
|
||||
'checks_ran_at' => now('UTC')->toIso8601String(),
|
||||
'preview_summary' => [
|
||||
'generated_at' => now('UTC')->toIso8601String(),
|
||||
'policies_total' => 1,
|
||||
'policies_changed' => 0,
|
||||
'assignments_changed' => 0,
|
||||
'scope_tags_changed' => 0,
|
||||
'raw_payload_marker' => 'spec332 raw payload should stay hidden',
|
||||
],
|
||||
'preview_diffs' => [[
|
||||
'policy_identifier' => 'spec332-policy-usable',
|
||||
'display_name' => 'Spec332 Device Policy',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'platform' => 'windows',
|
||||
'action' => 'update',
|
||||
'assignments_changed' => false,
|
||||
'scope_tags_changed' => false,
|
||||
'diff' => [
|
||||
'summary' => ['added' => 0, 'removed' => 0, 'changed' => 0],
|
||||
'changed' => [],
|
||||
'added' => [],
|
||||
'removed' => [],
|
||||
],
|
||||
]],
|
||||
'preview_ran_at' => now('UTC')->toIso8601String(),
|
||||
];
|
||||
|
||||
$data['check_basis'] = $resolver->checksBasisFromData($data);
|
||||
$data['preview_basis'] = $resolver->previewBasisFromData($data);
|
||||
|
||||
return RestoreRunResource::synchronizeRestoreSafetyDraft($data);
|
||||
}
|
||||
|
||||
it('keeps only the reconciled spec 332 directory under the active product process flow path', function (): void {
|
||||
$spec332Directories = collect(glob(repo_path('specs/332-*')) ?: [])
|
||||
->map(static fn (string $path): string => basename($path))
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($spec332Directories)->toBe([
|
||||
'332-product-process-flow-system-v1',
|
||||
])
|
||||
->and(is_dir(repo_path('specs/332-product-process-flow-system-v1')))->toBeTrue()
|
||||
->and(is_dir(repo_path('specs/332-restore-run-preview-productization')))->toBeFalse()
|
||||
->and((string) file_get_contents(repo_path('specs/332-product-process-flow-system-v1/spec.md')))
|
||||
->toContain('Product Process Flow System v1')
|
||||
->toContain('Spec 332 was reconciled from the narrower `specs/332-restore-run-preview-productization` path');
|
||||
});
|
||||
|
||||
it('renders the full product process flow on step 1 for a usable backup source', function (): void {
|
||||
[$user, $tenant] = spec332ProductProcessFlowTenant();
|
||||
[$backupSet] = spec332UsableBackupFixture($tenant);
|
||||
|
||||
$component = spec332WizardComponent($user, $tenant)
|
||||
->fillForm([
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
])
|
||||
->assertSee('Restore Safety')
|
||||
->assertSee('Backup quality summary')
|
||||
->assertSee('Restore safety gates')
|
||||
->assertSee('Restore Proof')
|
||||
->assertSee('Diagnostics - Collapsed')
|
||||
->assertSee('Input quality signals do not prove that execution is safe or that recovery is verified.')
|
||||
->assertSee('A usable source backup is selected for this restore draft.')
|
||||
->assertSee('Continue to scope and resolve required mappings.')
|
||||
->assertSee('Validate impact before execution.')
|
||||
->assertSee('This create flow does not prove recoverability before execution and post-run evidence exist.')
|
||||
->assertDontSee('Technical startability')
|
||||
->assertDontSee('write-gate')
|
||||
->assertDontSee('hard-blocker')
|
||||
->assertDontSee('Is this dangerous?')
|
||||
->assertDontSee('tenant-wide recoverability')
|
||||
->assertSeeHtml('data-step-label="Usable source selected"')
|
||||
->assertSeeHtml('data-proof-label="Operation proof"');
|
||||
|
||||
expect($component->html())->toContain('data-testid="restore-run-process-flow-full"');
|
||||
});
|
||||
|
||||
it('does not mark usable source as complete when the backup has no captured items', function (): void {
|
||||
[$user, $tenant] = spec332ProductProcessFlowTenant();
|
||||
$backupSet = spec332EmptyBackupFixture($tenant);
|
||||
|
||||
spec332WizardComponent($user, $tenant)
|
||||
->fillForm([
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
])
|
||||
->assertSee('Input quality signals do not prove that execution is safe or that recovery is verified.')
|
||||
->assertSeeHtml('data-step-label="Usable source selected"')
|
||||
->assertSeeHtml('data-step-status="required"');
|
||||
});
|
||||
|
||||
it('renders compact restore safety status on step 2 while keeping restore proof visible', function (): void {
|
||||
[$user, $tenant] = spec332ProductProcessFlowTenant();
|
||||
[$backupSet] = spec332UsableBackupFixture($tenant);
|
||||
|
||||
$component = spec332WizardComponent($user, $tenant)
|
||||
->fillForm([
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
])
|
||||
->goToNextWizardStep()
|
||||
->assertWizardCurrentStep(2)
|
||||
->assertSee('Restore safety status')
|
||||
->assertSee('2/7 gates complete')
|
||||
->assertSee('View safety gates')
|
||||
->assertSee('Restore Proof')
|
||||
->assertSee('Requested by')
|
||||
->assertSee('Diagnostics - Collapsed')
|
||||
->assertSeeHtml('data-testid="restore-run-process-flow-compact"');
|
||||
|
||||
expect($component->html())->toContain('data-testid="restore-run-process-flow-compact"');
|
||||
});
|
||||
|
||||
it('keeps group mapping details collapsed by default on step 2 until the resolver is opened explicitly', function (): void {
|
||||
[$user, $tenant] = spec332ProductProcessFlowTenant();
|
||||
[$backupSet] = spec332UnresolvedGroupBackupFixture($tenant);
|
||||
|
||||
$component = spec332WizardComponent($user, $tenant)
|
||||
->fillForm([
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
])
|
||||
->goToNextWizardStep()
|
||||
->assertWizardCurrentStep(2)
|
||||
->assertSee('Scope summary')
|
||||
->assertSee('Resolve mappings')
|
||||
->assertSee('Resolve target mappings')
|
||||
->assertSee('Restore safety status')
|
||||
->assertSee('Restore Proof')
|
||||
->assertSee('0 of 1 mappings resolved')
|
||||
->assertSee('1 unresolved')
|
||||
->assertSee('0 skipped')
|
||||
->assertSee('Resolve required mappings before validation can run.')
|
||||
->assertSee('Select a target group from the directory cache or enter a target group object ID as a fallback. Required mappings must be resolved before validation can run.')
|
||||
->assertDontSee('Paste the target Entra ID group Object ID (GUID).');
|
||||
|
||||
$html = html_entity_decode($component->html(), ENT_QUOTES | ENT_HTML5);
|
||||
|
||||
expect(preg_match('/isCollapsed:\s*true[\s\S]{0,2200}Resolve target mappings/', $html))->toBe(1);
|
||||
});
|
||||
|
||||
it('shows cached target group identity in mapping helper text when a cached target is selected', function (): void {
|
||||
[$user, $tenant] = spec332ProductProcessFlowTenant();
|
||||
[$backupSet] = spec332UnresolvedGroupBackupFixture($tenant);
|
||||
|
||||
$sourceGroupId = '11111111-1111-1111-1111-111111111111';
|
||||
$targetGroupId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||
|
||||
EntraGroup::factory()->for($tenant)->create([
|
||||
'entra_id' => $targetGroupId,
|
||||
'display_name' => 'Spec332 Cached Target Group',
|
||||
]);
|
||||
|
||||
spec332WizardComponent($user, $tenant)
|
||||
->fillForm([
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
])
|
||||
->goToNextWizardStep()
|
||||
->assertWizardCurrentStep(2)
|
||||
->fillForm([
|
||||
'group_mapping' => [
|
||||
$sourceGroupId => $targetGroupId,
|
||||
],
|
||||
])
|
||||
->assertSee('Target group: Spec332 Cached Target Group')
|
||||
->assertSee('Target ID: '.$targetGroupId);
|
||||
});
|
||||
|
||||
it('labels manual GUID mapping as a manual fallback and counts it in the resolver summary', function (): void {
|
||||
[$user, $tenant] = spec332ProductProcessFlowTenant();
|
||||
[$backupSet] = spec332UnresolvedGroupBackupFixture($tenant);
|
||||
|
||||
$sourceGroupId = '11111111-1111-1111-1111-111111111111';
|
||||
$targetGroupId = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
|
||||
|
||||
spec332WizardComponent($user, $tenant)
|
||||
->fillForm([
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
])
|
||||
->goToNextWizardStep()
|
||||
->assertWizardCurrentStep(2)
|
||||
->fillForm([
|
||||
'group_mapping' => [
|
||||
$sourceGroupId => $targetGroupId,
|
||||
],
|
||||
])
|
||||
->assertSee('1 of 1 mappings resolved')
|
||||
->assertSee('0 unresolved')
|
||||
->assertSee('0 skipped')
|
||||
->assertSee('1 manual fallback')
|
||||
->assertSee('Manual target object ID')
|
||||
->assertSee('Badge: Manual fallback');
|
||||
});
|
||||
|
||||
it('does not treat invalid GUID values as resolved in the resolver summary', function (): void {
|
||||
[$user, $tenant] = spec332ProductProcessFlowTenant();
|
||||
[$backupSet] = spec332UnresolvedGroupBackupFixture($tenant);
|
||||
|
||||
$sourceGroupId = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
spec332WizardComponent($user, $tenant)
|
||||
->fillForm([
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
])
|
||||
->goToNextWizardStep()
|
||||
->assertWizardCurrentStep(2)
|
||||
->fillForm([
|
||||
'group_mapping' => [
|
||||
$sourceGroupId => 'not-a-guid',
|
||||
],
|
||||
])
|
||||
->assertSee('0 of 1 mappings resolved')
|
||||
->assertSee('1 unresolved')
|
||||
->assertSee('Invalid group object ID (GUID).');
|
||||
});
|
||||
|
||||
it('supports skipping and undoing a mapping assignment on step 2', function (): void {
|
||||
[$user, $tenant] = spec332ProductProcessFlowTenant();
|
||||
[$backupSet] = spec332UnresolvedGroupBackupFixture($tenant);
|
||||
|
||||
$sourceGroupId = '11111111-1111-1111-1111-111111111111';
|
||||
$sourceGroupToken = '11111111_1111_1111_1111_111111111111';
|
||||
|
||||
$component = spec332WizardComponent($user, $tenant)
|
||||
->fillForm([
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
])
|
||||
->goToNextWizardStep()
|
||||
->assertWizardCurrentStep(2)
|
||||
->assertFormComponentActionVisible("group_mapping.{$sourceGroupId}", "skip_assignment_{$sourceGroupToken}")
|
||||
->callFormComponentAction("group_mapping.{$sourceGroupId}", "skip_assignment_{$sourceGroupToken}")
|
||||
->assertSet("data.group_mapping.{$sourceGroupId}", 'SKIP')
|
||||
->assertFormFieldHidden("group_mapping.{$sourceGroupId}")
|
||||
->assertFormFieldVisible("group_mapping_skipped_{$sourceGroupToken}")
|
||||
->assertSee('1 skipped')
|
||||
->assertSee('This assignment will not be restored.')
|
||||
->assertFormComponentActionVisible("group_mapping_skipped_{$sourceGroupToken}", "undo_skip_assignment_{$sourceGroupToken}")
|
||||
->callFormComponentAction("group_mapping_skipped_{$sourceGroupToken}", "undo_skip_assignment_{$sourceGroupToken}")
|
||||
->assertSet("data.group_mapping.{$sourceGroupId}", null)
|
||||
->assertFormFieldVisible("group_mapping.{$sourceGroupId}");
|
||||
|
||||
expect($component)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('blocks progression out of step 2 while required mappings are unresolved', function (): void {
|
||||
[$user, $tenant] = spec332ProductProcessFlowTenant();
|
||||
[$backupSet] = spec332UnresolvedGroupBackupFixture($tenant);
|
||||
|
||||
$component = spec332WizardComponent($user, $tenant)
|
||||
->fillForm([
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
])
|
||||
->goToNextWizardStep()
|
||||
->assertWizardCurrentStep(2);
|
||||
|
||||
$component
|
||||
->goToNextWizardStep()
|
||||
->assertWizardCurrentStep(2)
|
||||
->assertNotified('Mappings required')
|
||||
->assertSee('Resolve required mappings before validation can run.');
|
||||
});
|
||||
|
||||
it('productizes the empty target group picker when step 2 needs dependency mapping', function (): void {
|
||||
[$user, $tenant] = spec332ProductProcessFlowTenant();
|
||||
[$backupSet, $backupItem] = spec332UnresolvedGroupBackupFixture($tenant);
|
||||
|
||||
$component = spec332WizardComponent($user, $tenant)
|
||||
->fillForm([
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
])
|
||||
->goToNextWizardStep()
|
||||
->assertWizardCurrentStep(2);
|
||||
|
||||
$component
|
||||
->fillForm([
|
||||
'scope_mode' => 'selected',
|
||||
'backup_item_ids' => [(int) $backupItem->getKey()],
|
||||
]);
|
||||
|
||||
/** @var \Filament\Schemas\Schema $schema */
|
||||
$schema = $component->instance()->form;
|
||||
$field = $schema->getComponentByStatePath('group_mapping.11111111-1111-1111-1111-111111111111');
|
||||
$action = collect($field?->getSuffixActions() ?? [])
|
||||
->first(fn ($candidate) => $candidate->getName() === 'select_from_directory_cache_11111111_1111_1111_1111_111111111111');
|
||||
|
||||
expect($field)->not->toBeNull()
|
||||
->and($action)->not->toBeNull()
|
||||
->and($action->getModalHeading())->toBe('Resolve target group mapping');
|
||||
});
|
||||
|
||||
it('shows a product-safe blocked state for checks when provider credentials are missing', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create([
|
||||
'rbac_status' => 'ok',
|
||||
'rbac_last_checked_at' => now(),
|
||||
]);
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
ensureDefaultProviderConnection($tenant, 'microsoft', ensureCredential: false);
|
||||
[$backupSet] = spec332UsableBackupFixture($tenant);
|
||||
|
||||
$component = spec332WizardComponent($user, $tenant)
|
||||
->fillForm([
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
])
|
||||
->goToNextWizardStep()
|
||||
->goToNextWizardStep()
|
||||
->assertWizardCurrentStep(3)
|
||||
->assertSee('Validation blocked')
|
||||
->assertSee('Provider credentials are not available for this environment.')
|
||||
->assertSee('Restore checks cannot run until the provider connection is repaired.')
|
||||
->assertSee('Review provider connection')
|
||||
->assertDontSee('Provider credentials are missing');
|
||||
|
||||
$component
|
||||
->goToNextWizardStep()
|
||||
->assertWizardCurrentStep(3)
|
||||
->assertNotified('Validation blocked');
|
||||
|
||||
expect($component->html())->not->toContain('Exception');
|
||||
});
|
||||
|
||||
it('keeps preview decision-first while showing compact safety status and restore proof', function (): void {
|
||||
[$user, $tenant] = spec332ProductProcessFlowTenant();
|
||||
[$backupSet, $backupItem] = spec332UsableBackupFixture($tenant);
|
||||
$data = spec332CurrentPreviewData($backupSet, $backupItem);
|
||||
|
||||
$component = spec332WizardComponent($user, $tenant)
|
||||
->set('data', $data)
|
||||
->goToWizardStep(4)
|
||||
->assertWizardCurrentStep(4)
|
||||
->assertSee('Review the preview and complete confirmation before execution can be queued.')
|
||||
->assertDontSee('Review prerequisites before execution.')
|
||||
->assertSee('Restore safety status')
|
||||
->assertSee('Next gate:')
|
||||
->assertSee('Confirmation')
|
||||
->assertSee('Restore Proof')
|
||||
->assertSee('Operation proof')
|
||||
->assertSee('Post-run evidence')
|
||||
->assertSee('Diagnostics - Collapsed')
|
||||
->assertDontSee('spec332 raw payload should stay hidden')
|
||||
->assertDontSee('tenant-wide recovery is proven');
|
||||
});
|
||||
|
||||
it('keeps confirm step locked when execution prerequisites are unavailable', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create([
|
||||
'rbac_status' => 'ok',
|
||||
'rbac_last_checked_at' => now(),
|
||||
]);
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
ensureDefaultProviderConnection($tenant, 'microsoft', ensureCredential: false);
|
||||
[$backupSet, $backupItem] = spec332UsableBackupFixture($tenant);
|
||||
$data = spec332CurrentPreviewData($backupSet, $backupItem);
|
||||
|
||||
$component = spec332WizardComponent($user, $tenant)
|
||||
->set('data', $data)
|
||||
->goToWizardStep(5)
|
||||
->assertWizardCurrentStep(5)
|
||||
->assertSee('Confirmation summary')
|
||||
->assertSee('Execution')
|
||||
->assertSee('Unavailable')
|
||||
->assertSee('Review prerequisites before execution.')
|
||||
->assertSee('Restore execution is blocked until required prerequisites are healthy again. Evidence does not exist yet.')
|
||||
->assertSee('Confirmation does not claim recovery.')
|
||||
->assertFormFieldDisabled('is_dry_run');
|
||||
|
||||
expect($component->html())->not->toContain('Operation proof is complete');
|
||||
});
|
||||
|
||||
it('shows confirm step readiness when execution prerequisites are healthy', function (): void {
|
||||
[$user, $tenant] = spec332ProductProcessFlowTenant();
|
||||
[$backupSet, $backupItem] = spec332UsableBackupFixture($tenant);
|
||||
$data = spec332CurrentPreviewData($backupSet, $backupItem);
|
||||
|
||||
spec332WizardComponent($user, $tenant)
|
||||
->set('data', $data)
|
||||
->goToWizardStep(5)
|
||||
->assertWizardCurrentStep(5)
|
||||
->assertSee('Confirmation summary')
|
||||
->assertSee('Execution')
|
||||
->assertSee('Available after confirmation')
|
||||
->assertSee('Confirmation does not claim recovery.')
|
||||
->assertFormFieldEnabled('is_dry_run');
|
||||
});
|
||||
@ -5,8 +5,8 @@
|
||||
use App\Filament\Pages\EnvironmentDashboard;
|
||||
use App\Filament\Resources\ManagedEnvironmentResource;
|
||||
use App\Filament\Resources\ManagedEnvironmentResource\Pages\ListManagedEnvironments;
|
||||
use App\Models\Policy;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\Policy;
|
||||
use App\Models\User;
|
||||
use App\Support\BackupHealth\BackupFreshnessEvaluation;
|
||||
use App\Support\BackupHealth\BackupScheduleFollowUpEvaluation;
|
||||
@ -19,8 +19,6 @@
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
use function Pest\Laravel\mock;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
afterEach(function (): void {
|
||||
@ -122,7 +120,7 @@ function tenantRegistryRecoveryEvidence(
|
||||
return [
|
||||
'overview_state' => $overviewState,
|
||||
'summary' => $summary,
|
||||
'claim_boundary' => 'ManagedEnvironment-wide recovery is not proven.',
|
||||
'claim_boundary' => 'Target environment recovery is not proven.',
|
||||
'reason' => $reason,
|
||||
'latest_relevant_restore_run' => null,
|
||||
'latest_relevant_attention' => null,
|
||||
|
||||
@ -67,6 +67,6 @@
|
||||
->test(TenantlessOperationRunViewer::class, ['run' => $operationRun])
|
||||
->assertSee('Restore continuation')
|
||||
->assertSee('Follow-up required')
|
||||
->assertSee('ManagedEnvironment-wide recovery is not proven.')
|
||||
->assertSee('Target environment recovery is not proven.')
|
||||
->assertSee('Open restore run');
|
||||
});
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
||||
use App\Livewire\EntraGroupCachePickerTable;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\Policy;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Graph\GroupResolver;
|
||||
use App\Support\RestoreSafety\RestoreScopeFingerprint;
|
||||
@ -99,6 +101,105 @@
|
||||
$component->assertFormFieldVisible('group_mapping.source-group-1');
|
||||
});
|
||||
|
||||
test('restore group mapping picker productizes the empty directory cache state', function () {
|
||||
$tenant = ManagedEnvironment::factory()->create([
|
||||
'managed_environment_id' => 'tenant-1',
|
||||
'name' => 'ManagedEnvironment One',
|
||||
'metadata' => [],
|
||||
'rbac_status' => 'ok',
|
||||
'rbac_last_checked_at' => now(),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
|
||||
$this->actingAs($user);
|
||||
setAdminPanelContext($tenant);
|
||||
|
||||
$component = Livewire::actingAs($user)->test(EntraGroupCachePickerTable::class, [
|
||||
'sourceGroupId' => '00000000-0000-0000-0000-d908d2dd',
|
||||
'sourceGroupDisplayName' => 'ADSyncOperators',
|
||||
'tenantId' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$component
|
||||
->assertSee('Source group')
|
||||
->assertSee('ADSyncOperators')
|
||||
->assertSee('Source ID: 00000000-0000-0000-0000-d908d2dd')
|
||||
->assertSee('No directory group cache available')
|
||||
->assertSee('TenantPilot needs cached directory groups before target mappings can be selected.')
|
||||
->assertSee('Sync directory groups, then return to this mapping.')
|
||||
->assertSee('Open group sync')
|
||||
->assertSee('View group sync operations')
|
||||
->assertDontSee('No cached groups found')
|
||||
->assertDontSee('Directory Groups')
|
||||
->assertDontSee('No groups found in tenant')
|
||||
->assertDontSee('Search groups…')
|
||||
->assertDontSee('Stale');
|
||||
|
||||
$html = html_entity_decode($component->html(), ENT_QUOTES | ENT_HTML5);
|
||||
$expectedGroupSyncUrl = \App\Filament\Resources\EntraGroupResource::getUrl('index', tenant: $tenant);
|
||||
$expectedOperationsUrl = \App\Support\OperationRunLinks::index($tenant, operationType: 'directory.groups.sync');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-testid="restore-group-picker-empty-state"')
|
||||
->toContain('href="'.$expectedGroupSyncUrl.'"')
|
||||
->toContain('href="'.$expectedOperationsUrl.'"')
|
||||
->toContain('target="_blank"')
|
||||
->not->toMatch('/>\s*Operations\s*</');
|
||||
});
|
||||
|
||||
test('restore group mapping picker lists cached directory groups for the current environment only', function () {
|
||||
$tenant = ManagedEnvironment::factory()->create([
|
||||
'managed_environment_id' => 'tenant-1',
|
||||
'name' => 'ManagedEnvironment One',
|
||||
'metadata' => [],
|
||||
'rbac_status' => 'ok',
|
||||
'rbac_last_checked_at' => now(),
|
||||
]);
|
||||
|
||||
$otherTenant = ManagedEnvironment::factory()->create([
|
||||
'managed_environment_id' => 'tenant-2',
|
||||
'name' => 'ManagedEnvironment Two',
|
||||
'metadata' => [],
|
||||
'rbac_status' => 'ok',
|
||||
'rbac_last_checked_at' => now(),
|
||||
]);
|
||||
|
||||
$cached = EntraGroup::factory()->for($tenant)->create([
|
||||
'display_name' => 'Spec332 Cached Group',
|
||||
'entra_id' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
]);
|
||||
|
||||
EntraGroup::factory()->for($otherTenant)->create([
|
||||
'display_name' => 'Foreign Cached Group',
|
||||
'entra_id' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
|
||||
$this->actingAs($user);
|
||||
setAdminPanelContext($tenant);
|
||||
|
||||
$component = Livewire::actingAs($user)->test(EntraGroupCachePickerTable::class, [
|
||||
'sourceGroupId' => '00000000-0000-0000-0000-d908d2dd',
|
||||
'sourceGroupDisplayName' => 'ADSyncOperators',
|
||||
'tenantId' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$component
|
||||
->assertSee('Source group')
|
||||
->assertSee('ADSyncOperators')
|
||||
->assertSee('Source ID: 00000000-0000-0000-0000-d908d2dd')
|
||||
->assertSee('Spec332 Cached Group')
|
||||
->assertDontSee('Foreign Cached Group')
|
||||
->assertDontSee('No directory group cache available');
|
||||
|
||||
$html = html_entity_decode($component->html(), ENT_QUOTES | ENT_HTML5);
|
||||
|
||||
expect($html)
|
||||
->toContain('data-testid="restore-group-picker-table"');
|
||||
});
|
||||
|
||||
test('restore wizard persists group mapping selections', function () {
|
||||
$tenant = ManagedEnvironment::factory()->create([
|
||||
'managed_environment_id' => 'tenant-1',
|
||||
|
||||
@ -3,9 +3,9 @@
|
||||
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\Policy;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Graph\GroupResolver;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@ -93,7 +93,8 @@
|
||||
])
|
||||
->goToNextWizardStep()
|
||||
->assertFormComponentActionVisible('check_results', 'run_restore_checks')
|
||||
->callFormComponentAction('check_results', 'run_restore_checks');
|
||||
->callFormComponentAction('check_results', 'run_restore_checks')
|
||||
->assertNotified('Safety checks finished with blockers');
|
||||
|
||||
$summary = $component->get('data.check_summary');
|
||||
$results = $component->get('data.check_results');
|
||||
|
||||
@ -66,6 +66,7 @@
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
|
||||
$this->actingAs($user);
|
||||
setAdminPanelContext($tenant);
|
||||
ensureDefaultProviderConnection($tenant, 'microsoft');
|
||||
|
||||
$data = [
|
||||
'backup_set_id' => $backupSet->id,
|
||||
|
||||
@ -63,8 +63,8 @@
|
||||
expect($safe->color)->toBe('success');
|
||||
|
||||
$current = BadgeCatalog::spec(BadgeDomain::RestoreCheckSeverity, 'current');
|
||||
expect($current->label)->toBe('Current checks');
|
||||
expect($current->color)->toBe('success');
|
||||
expect($current->label)->toBe('Latest check result');
|
||||
expect($current->color)->toBe('info');
|
||||
|
||||
$invalidated = BadgeCatalog::spec(BadgeDomain::RestoreCheckSeverity, 'invalidated');
|
||||
expect($invalidated->label)->toBe('Invalidated');
|
||||
|
||||
@ -7,11 +7,11 @@
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('formats unresolved labels using an ellipsis + last 8 chars', function () {
|
||||
it('formats unknown labels using an ellipsis + last 8 chars', function () {
|
||||
$id = '11111111-2222-3333-4444-555555555555';
|
||||
|
||||
expect(EntraGroupLabelResolver::formatLabel(null, $id))
|
||||
->toBe('Unresolved (…55555555)');
|
||||
->toBe('Unknown group (…55555555)');
|
||||
});
|
||||
|
||||
it('resolves labels from the tenant cache (tenant-scoped)', function () {
|
||||
@ -46,5 +46,5 @@
|
||||
$resolver = app(EntraGroupLabelResolver::class);
|
||||
|
||||
expect($resolver->resolveOne($tenant, 'group-123'))
|
||||
->toBe('Unresolved (group123)');
|
||||
->toBe('Unknown group (group123)');
|
||||
});
|
||||
|
||||
@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource\Presenters\RestoreRunCreatePresenter;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Policy;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('recomputes the restore create presenter contract from current database state', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
ensureDefaultProviderConnection($tenant, 'microsoft');
|
||||
|
||||
$policy = Policy::create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'spec332-presenter-policy',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Spec332 Presenter Policy',
|
||||
'platform' => 'windows',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Spec332 Presenter Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_identifier' => $policy->external_id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'payload' => [],
|
||||
'assignments' => [],
|
||||
'metadata' => [
|
||||
'displayName' => 'Spec332 Metadata Only Policy',
|
||||
'snapshot_source' => 'metadata_only',
|
||||
'warnings' => ['metadata only fallback'],
|
||||
],
|
||||
]);
|
||||
|
||||
$wizardData = [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'scope_mode' => 'all',
|
||||
'backup_item_ids' => [],
|
||||
'group_mapping' => [],
|
||||
];
|
||||
|
||||
$first = RestoreRunCreatePresenter::contract(
|
||||
data: $wizardData,
|
||||
currentStep: 1,
|
||||
compactFlow: false,
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
);
|
||||
|
||||
$firstSummary = data_get($first, 'processFlow.steps.0.summary');
|
||||
|
||||
expect($firstSummary)
|
||||
->toBeString()
|
||||
->toContain('does not contain a usable captured item yet');
|
||||
|
||||
$backupItem->update([
|
||||
'payload' => [
|
||||
'id' => 'spec332-presenter-policy',
|
||||
'displayName' => 'Spec332 Presenter Policy',
|
||||
'settings' => ['foo' => 'bar'],
|
||||
],
|
||||
'metadata' => [
|
||||
'displayName' => 'Spec332 Presenter Policy',
|
||||
],
|
||||
]);
|
||||
|
||||
$second = RestoreRunCreatePresenter::contract(
|
||||
data: $wizardData,
|
||||
currentStep: 1,
|
||||
compactFlow: false,
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
);
|
||||
|
||||
$secondSummary = data_get($second, 'processFlow.steps.0.summary');
|
||||
|
||||
expect($secondSummary)
|
||||
->toBeString()
|
||||
->toContain('A usable source backup is selected for this restore draft.')
|
||||
->not->toContain('does not contain a usable captured item yet');
|
||||
});
|
||||
|
||||
it('does not leak presenter state between independent restore draft contracts', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
ensureDefaultProviderConnection($tenant, 'microsoft');
|
||||
|
||||
$policy = Policy::create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'spec332-presenter-policy-b',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Spec332 Presenter Policy B',
|
||||
'platform' => 'windows',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
$metadataOnlyBackup = BackupSet::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Spec332 Presenter Metadata Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
BackupItem::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'backup_set_id' => (int) $metadataOnlyBackup->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_identifier' => $policy->external_id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'payload' => [],
|
||||
'assignments' => [],
|
||||
'metadata' => [
|
||||
'displayName' => 'Spec332 Metadata Only Policy',
|
||||
'snapshot_source' => 'metadata_only',
|
||||
],
|
||||
]);
|
||||
|
||||
$first = RestoreRunCreatePresenter::contract(
|
||||
data: [
|
||||
'backup_set_id' => (int) $metadataOnlyBackup->getKey(),
|
||||
'scope_mode' => 'all',
|
||||
'backup_item_ids' => [],
|
||||
'group_mapping' => [],
|
||||
],
|
||||
currentStep: 1,
|
||||
compactFlow: false,
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
);
|
||||
|
||||
expect(data_get($first, 'processFlow.steps.0.summary'))
|
||||
->toBeString()
|
||||
->toContain('does not contain a usable captured item yet');
|
||||
|
||||
$usableBackup = BackupSet::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Spec332 Presenter Usable Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
BackupItem::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'backup_set_id' => (int) $usableBackup->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_identifier' => $policy->external_id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'payload' => [
|
||||
'id' => 'spec332-presenter-policy-b',
|
||||
'displayName' => 'Spec332 Presenter Policy B',
|
||||
'settings' => ['foo' => 'bar'],
|
||||
],
|
||||
'assignments' => [],
|
||||
'metadata' => [
|
||||
'displayName' => 'Spec332 Presenter Policy B',
|
||||
],
|
||||
]);
|
||||
|
||||
$second = RestoreRunCreatePresenter::contract(
|
||||
data: [
|
||||
'backup_set_id' => (int) $usableBackup->getKey(),
|
||||
'scope_mode' => 'all',
|
||||
'backup_item_ids' => [],
|
||||
'group_mapping' => [],
|
||||
],
|
||||
currentStep: 1,
|
||||
compactFlow: false,
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
);
|
||||
|
||||
expect(data_get($second, 'processFlow.steps.0.summary'))
|
||||
->toBeString()
|
||||
->toContain('A usable source backup is selected for this restore draft.')
|
||||
->not->toContain('does not contain a usable captured item yet');
|
||||
});
|
||||
@ -149,7 +149,7 @@
|
||||
expect($overview['overview_state'])->toBe('weakened')
|
||||
->and($overview['latest_relevant_restore_run_id'])->toBe((int) $latestFailed->getKey())
|
||||
->and($overview['latest_relevant_attention_state'])->toBe(RestoreResultAttention::STATE_FAILED)
|
||||
->and($overview['claim_boundary'])->toBe('ManagedEnvironment recovery is not proven.')
|
||||
->and($overview['claim_boundary'])->toBe('Target environment recovery is not proven.')
|
||||
->and($overview['reason'])->toBe(RestoreResultAttention::STATE_FAILED);
|
||||
});
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 294 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 221 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 299 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 238 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 232 KiB |
90
specs/332-product-process-flow-system-v1/plan.md
Normal file
90
specs/332-product-process-flow-system-v1/plan.md
Normal file
@ -0,0 +1,90 @@
|
||||
# Implementation Plan: Spec 332 - Product Process Flow System v1
|
||||
|
||||
- Branch: `332-product-process-flow-system-v1`
|
||||
- Date: 2026-05-25
|
||||
- Spec: `specs/332-product-process-flow-system-v1/spec.md`
|
||||
|
||||
## Reconciliation Note
|
||||
|
||||
Spec 332 was reconciled from the narrower `restore-run-preview-productization` path into the intended `product-process-flow-system-v1` scope. The previous path underrepresented the actual product/process-flow goal and caused restore safety state to drift too late into Preview.
|
||||
|
||||
## Summary
|
||||
|
||||
Implement a reusable Product Process Flow pattern and apply it first to Restore Run Create:
|
||||
|
||||
- Step 1 shows the full restore safety/product process flow, decision card, backup quality summary, restore proof aside, and collapsed diagnostics.
|
||||
- Step 2 and later keep step-specific content primary while exposing compact restore safety status.
|
||||
- Step 2 keeps dependency mapping in an explicit resolver mode with progress copy, shared guidance, and blocked progression until required mappings are resolved.
|
||||
- The Step 2 dependency-mapping picker uses task-specific cache-empty guidance instead of a generic directory-cache table empty state.
|
||||
- Preview stays decision-first.
|
||||
- Confirm stays high-friction before execution.
|
||||
|
||||
## Affected Surfaces / Files
|
||||
|
||||
- Wizard logic:
|
||||
- `apps/platform/app/Filament/Resources/RestoreRunResource.php`
|
||||
- Restore safety copy:
|
||||
- `apps/platform/app/Support/RestoreSafety/RestoreSafetyCopy.php`
|
||||
- Product Process Flow views:
|
||||
- `apps/platform/resources/views/filament/forms/components/restore-run-checks.blade.php`
|
||||
- `apps/platform/resources/views/filament/forms/components/restore-run-preview.blade.php`
|
||||
- shared/new restore-run process-flow partials as needed
|
||||
- Group picker modal + component:
|
||||
- `apps/platform/resources/views/filament/modals/entra-group-cache-picker.blade.php`
|
||||
- `apps/platform/resources/views/livewire/entra-group-cache-picker-table.blade.php`
|
||||
- `apps/platform/app/Livewire/EntraGroupCachePickerTable.php`
|
||||
- Tests:
|
||||
- `apps/platform/tests/Feature/Filament/RestoreRunPreviewProductizationTest.php`
|
||||
- restore-run create wizard feature tests covering step 1 through confirm
|
||||
- `apps/platform/tests/Browser/Spec332RestoreRunWizardProductProcessFlowSmokeTest.php`
|
||||
|
||||
## Technical Approach
|
||||
|
||||
1. **Spec reconciliation**
|
||||
- Keep only `specs/332-product-process-flow-system-v1`.
|
||||
- Update spec/plan/tasks so Product Process Flow is the primary scope and Restore Preview productization is one slice of that scope.
|
||||
|
||||
2. **Shared Product Process Flow state**
|
||||
- Reuse existing `wizardSafetyState`, restore safety assessment, preview integrity, execution readiness, and backup quality summary helpers.
|
||||
- Add or extract presentation helpers/partials so Step 1 full flow and Step 2+ compact status read from the same underlying state.
|
||||
|
||||
3. **Step 1 contract**
|
||||
- Restore Safety decision card above the selector.
|
||||
- Backup set selector and backup quality summary.
|
||||
- Full vertical `Restore safety gates`.
|
||||
- Restore Proof aside with diagnostics collapsed.
|
||||
|
||||
4. **Later-step contract**
|
||||
- Keep step-specific content primary.
|
||||
- Render compact Restore Safety Status plus proof context where useful.
|
||||
- Productize Step 2 resolver mode around collapsed-by-default mapping details, progress copy, blocked progression, and cache-empty follow-up.
|
||||
- Preserve gate enforcement with `afterValidation()` and `Halt`.
|
||||
|
||||
5. **Preview and confirm**
|
||||
- Preview remains decision-first and truthfully reflects gate state.
|
||||
- Confirm preserves high-friction confirmation without implying completed recovery proof.
|
||||
|
||||
6. **Tests**
|
||||
- Feature tests for full Step 1, compact Step 2+, blocked validation, truthful preview copy, proof persistence, collapsed diagnostics, and no false recovery-proof claims.
|
||||
- Browser smoke through Steps 1-4.
|
||||
|
||||
## Validation Commands
|
||||
|
||||
Narrow first:
|
||||
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Filament --filter=Spec332 --compact`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Filament/RestoreRun* --compact`
|
||||
|
||||
Browser smoke:
|
||||
|
||||
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec332RestoreRunWizardProductProcessFlowSmokeTest.php --compact`
|
||||
|
||||
Formatting:
|
||||
|
||||
- `cd apps/platform && ./vendor/bin/sail pint --dirty`
|
||||
- `git diff --check`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Existing restore safety / preview / backup quality resolvers remain the source of truth.
|
||||
- Spec 334 (nested Filament/Livewire context hardening) must remain present so browser smoke can run without tenantless Livewire context failures.
|
||||
193
specs/332-product-process-flow-system-v1/spec.md
Normal file
193
specs/332-product-process-flow-system-v1/spec.md
Normal file
@ -0,0 +1,193 @@
|
||||
# Feature Specification: Spec 332 - Product Process Flow System v1
|
||||
|
||||
- Feature Branch: `332-product-process-flow-system-v1`
|
||||
- Created: 2026-05-24
|
||||
- Updated: 2026-05-25
|
||||
- Status: Draft
|
||||
- Input: restore-run create wizard drift review + repo implementation + tests
|
||||
|
||||
## Reconciliation Note
|
||||
|
||||
Spec 332 was reconciled from the narrower `specs/332-restore-run-preview-productization` path into the intended `specs/332-product-process-flow-system-v1` scope. The previous path underrepresented the actual product/process-flow goal and caused restore safety state to drift too late into Preview.
|
||||
|
||||
Restore Preview productization remains part of Spec 332, but it is only one consumer slice. The primary deliverable is a reusable Product Process Flow pattern with Restore Run Create as the first consumer.
|
||||
|
||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Restore safety, proof, and gate progression became concentrated too late in the wizard. Step 1 drifted toward a plain backup selector, while meaningful restore readiness moved mostly into Preview.
|
||||
- **Today's failure**: Operators cannot judge restore viability from the start of the flow. This weakens safe decision-making, obscures the next blocked gate, and hides proof/evidence structure until late in a high-risk workflow.
|
||||
- **User-visible improvement**: Restore Run shows a reusable Product Process Flow from the first step: a decision card, backup quality summary, full vertical restore safety gates, restore proof aside, and collapsed diagnostics. Later steps show compact safety status, while Preview remains decision-first.
|
||||
- **Smallest enterprise-capable version**: Introduce a reusable Product Process Flow pattern and wire it into the existing Restore Run create wizard. Reuse existing restore safety, preview, and backup quality resolvers. No new persisted entities.
|
||||
- **Explicit non-goals**: No new restore execution engine, no new queue orchestration, no new Graph contract layer, no new risk taxonomy, and no new route family.
|
||||
- **Permanent complexity imported**: Shared wizard presentation pattern, bounded Blade/UI state, step gating, and focused feature/browser regression coverage.
|
||||
- **Why now**: Restore is high-risk and operator-critical. Safety/proof state must be truthful from step 1, not deferred until preview.
|
||||
- **Why not local**: The flow pattern is reusable across risky multi-step workflows. Leaving it embedded only in Preview encourages repeated drift and inconsistent operator guidance.
|
||||
- **Approval class**: Core Enterprise
|
||||
- **Red flags triggered**: UI surface behavior change (wizard), high-risk operator workflow, evidence/proof messaging. Defense: bounded reuse, explicit copy rules, feature tests, browser smoke.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant (environment-bound restore wizard)
|
||||
- **Primary Routes**:
|
||||
- `/admin/workspaces/{workspace}/environments/{environment}/restore-runs/create`
|
||||
- **Data Ownership**:
|
||||
- Uses existing `RestoreRun` draft state; no new tables.
|
||||
- Safety, proof, preview, diagnostics, and backup quality state remain derived from existing restore/backup resolvers.
|
||||
- **RBAC**:
|
||||
- Tenant membership required.
|
||||
- Existing restore capabilities remain the authority; this spec does not change authorization policy rules.
|
||||
|
||||
## UI Surface Impact *(mandatory — UI-COV-001)*
|
||||
|
||||
- [ ] No UI surface impact
|
||||
- [x] Existing page changed
|
||||
- [ ] New page/route added
|
||||
- [ ] Navigation changed
|
||||
- [ ] Filament panel/provider surface changed
|
||||
- [x] New modal/drawer/wizard/action added
|
||||
- [x] New table/form/state added
|
||||
- [ ] Customer-facing surface changed
|
||||
- [x] Dangerous action changed
|
||||
- [x] Status/evidence/review presentation changed
|
||||
- [ ] Workspace/environment context presentation changed
|
||||
|
||||
## UI/Productization Coverage *(mandatory)*
|
||||
|
||||
- **Route/page/surface**: Restore Run create wizard, with Product Process Flow pattern applied across Step 1 through Confirm.
|
||||
- **Design depth**: Manual Review Required (operator-critical, risky workflow).
|
||||
- **Repo-truth level**: repo-verified (feature + browser tests).
|
||||
- **New pattern required**: yes; reusable Product Process Flow pattern using existing restore safety / proof / backup quality state.
|
||||
- **Screenshot required**: no (covered by dedicated browser smoke assertions).
|
||||
- **Dangerous-action review required**: yes; execute restore remains high-friction and must not imply recovery proof before execution/post-run evidence exists.
|
||||
- **Coverage files updated or explicitly not needed**: `N/A - scope is covered via focused feature tests + browser smoke`.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Introduce a reusable Product Process Flow pattern for risky multi-step product workflows.
|
||||
2. Apply that pattern to Restore Run Create without changing the underlying restore domain model.
|
||||
3. Show restore safety from the start of the wizard, not only in Preview.
|
||||
4. Keep Preview decision-first, but no longer as the only place where safety/proof state appears.
|
||||
5. Keep Confirm high-friction before execution.
|
||||
6. Prevent false recovery-proof claims before execution/post-run evidence exists.
|
||||
|
||||
## Product Contract
|
||||
|
||||
### Step 1: Select Backup Set
|
||||
|
||||
Render, in this order:
|
||||
|
||||
1. Restore Safety decision card
|
||||
- Status
|
||||
- Reason
|
||||
- Impact
|
||||
- Primary next action
|
||||
2. Backup set selector
|
||||
3. Backup quality summary
|
||||
- item count
|
||||
- degraded items
|
||||
- metadata-only items
|
||||
- assignment issues
|
||||
- orphaned assignments
|
||||
- clear copy that input quality does not prove that execution is safe or that recovery is verified
|
||||
4. Full vertical Product Process Flow
|
||||
- Title: `Restore safety gates`
|
||||
- Steps:
|
||||
- Usable source selected
|
||||
- Target selected
|
||||
- Scope/dependency mapping
|
||||
- Validation
|
||||
- Preview
|
||||
- Confirmation
|
||||
- Execution and evidence when represented separately
|
||||
5. Restore Proof aside
|
||||
- Source backup
|
||||
- Target environment
|
||||
- Requested by
|
||||
- Restore scope
|
||||
- Operation proof
|
||||
- Post-run evidence
|
||||
- Diagnostics
|
||||
6. Diagnostics collapsed by default
|
||||
|
||||
### Step 2 and later
|
||||
|
||||
- Show step-specific content first.
|
||||
- Show compact Restore Safety Status instead of the full seven-gate flow by default.
|
||||
- Keep `Restore Proof` available where useful.
|
||||
- Keep dependency mapping details hidden by default until the resolver is opened explicitly.
|
||||
- When dependency mapping is expanded, present it as `Resolve target mappings` with resolver progress, one shared explanation, and no repeated per-row helper copy.
|
||||
- Block Next while required mappings remain unresolved.
|
||||
- When dependency mapping opens the target group picker without cached directory groups, show a task-specific empty state:
|
||||
- modal heading: `Resolve target group mapping`
|
||||
- source group context remains visible
|
||||
- empty state copy explains cache availability, not tenant group absence
|
||||
- primary CTA uses `Open group sync` unless direct in-modal sync is explicitly supported
|
||||
- search/filter controls do not dominate the modal when the cache is empty
|
||||
- Show:
|
||||
- completed gates count (for example `2/7 gates complete`)
|
||||
- next gate
|
||||
- execution available/unavailable state
|
||||
- `View safety gates`
|
||||
|
||||
### Step 3: Validate
|
||||
|
||||
- Validation blockers remain explicit.
|
||||
- Next is blocked when validation is blocked.
|
||||
|
||||
### Step 4: Preview
|
||||
|
||||
- Preview remains decision-first.
|
||||
- Compact safety status is shown by default.
|
||||
- Preview copy must reflect actual gate state and avoid false blocker language once Preview is complete.
|
||||
|
||||
### Step 5: Confirm
|
||||
|
||||
- Confirmation remains high-friction before execution.
|
||||
- No false recovery-proof claim is shown before execution/post-run evidence exists.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No changes to restore execution behavior, queue orchestration, or Graph contract paths.
|
||||
- No new persisted state families, tables, or enum taxonomies.
|
||||
- No separate “trust framework” outside the Product Process Flow pattern and Restore Run consumer.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Reuse existing restore safety, preview integrity, execution readiness, and backup quality resolvers.
|
||||
- Prefer shared Product Process Flow presentation primitives over one-off Preview-only copy.
|
||||
- Keep diagnostics collapsed by default and avoid raw payload presentation in the default wizard path.
|
||||
- Gating continues to use Filament wizard step lifecycle hooks and `Halt` where appropriate.
|
||||
|
||||
## Testing / Lane / Runtime Impact
|
||||
|
||||
- **Test purpose / classification**: Feature + Browser smoke
|
||||
- **Validation lanes**: confidence + browser
|
||||
- **Updated tests**:
|
||||
- Step 1 renders full Product Process Flow
|
||||
- Step 1 renders Restore Safety decision card
|
||||
- Step 1 renders Restore Proof aside
|
||||
- Step 1 usable source gate reflects actual backup contents
|
||||
- Step 2+ render compact safety status
|
||||
- Step 2 resolver mode stays explicit and blocks progression while required mappings remain unresolved
|
||||
- Step 2 empty group picker renders task-specific cache-empty guidance
|
||||
- Preview remains decision-first
|
||||
- Diagnostics remain collapsed
|
||||
- No false recovery-proof claims are visible
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Only one Spec 332 directory exists in the repo: `specs/332-product-process-flow-system-v1`.
|
||||
- Step 1 is not a bare backup selector page.
|
||||
- Step 1 renders the full Product Process Flow, Restore Safety decision card, backup quality summary, Restore Proof aside, and collapsed diagnostics.
|
||||
- A backup with usable captured items marks the usable source gate complete.
|
||||
- A backup with zero/no captured items does not mark the usable source gate complete.
|
||||
- Step 2 and later render compact Restore Safety Status by default, not the full gate flow.
|
||||
- Step 2 hides mapping details by default and only shows resolver details after explicit expansion.
|
||||
- Step 2 blocks Next until required mappings are resolved.
|
||||
- The Step 2 group mapping picker shows task-specific cache-empty guidance and does not imply the tenant has no groups.
|
||||
- Preview remains decision-first and is not the only place where safety/proof state appears.
|
||||
- Confirm remains high-friction before execution.
|
||||
- No false recovery-proof claim appears before execution/post-run evidence exists.
|
||||
- Feature tests and browser smoke pass.
|
||||
49
specs/332-product-process-flow-system-v1/tasks.md
Normal file
49
specs/332-product-process-flow-system-v1/tasks.md
Normal file
@ -0,0 +1,49 @@
|
||||
# Tasks: Spec 332 - Product Process Flow System v1
|
||||
|
||||
**Input**: `specs/332-product-process-flow-system-v1/spec.md`, `specs/332-product-process-flow-system-v1/plan.md`
|
||||
|
||||
## Reconciliation
|
||||
|
||||
- [x] Keep only one Spec 332 directory in the repo: `specs/332-product-process-flow-system-v1`.
|
||||
- [x] Document that Spec 332 was reconciled from the narrower `restore-run-preview-productization` path into the intended Product Process Flow scope.
|
||||
|
||||
## Phase 1: Product Process Flow pattern
|
||||
|
||||
- [x] Reuse existing restore safety, preview integrity, execution readiness, and backup quality state as the Product Process Flow source of truth.
|
||||
- [x] Add the shared Step 1 full-flow presentation for Restore Run Create.
|
||||
- [x] Add the shared compact later-step safety status presentation.
|
||||
|
||||
## Phase 2: Restore Run wizard contract
|
||||
|
||||
- [x] Step 1 renders Restore Safety decision card.
|
||||
- [x] Step 1 renders backup selector plus backup quality summary.
|
||||
- [x] Step 1 renders the full vertical `Restore safety gates` flow.
|
||||
- [x] Step 1 renders Restore Proof aside with diagnostics collapsed.
|
||||
- [x] Step 2+ render compact Restore Safety Status instead of the full gate flow by default.
|
||||
- [x] Step 2 hides mapping details by default and only reveals an explicit `Resolve target mappings` resolver when opened.
|
||||
- [x] Step 2 resolver shows progress copy and blocks Next until required mappings are resolved.
|
||||
- [x] Step 2 group mapping picker uses task-specific cache-empty guidance and source-group context instead of generic directory-cache empty copy.
|
||||
- [x] Step 3 blocks Next when validation blockers exist.
|
||||
- [x] Step 4 remains decision-first and reflects actual gate state.
|
||||
- [x] Step 5 remains high-friction before execution.
|
||||
- [x] No false recovery-proof claims appear before execution/post-run evidence exists.
|
||||
|
||||
## Phase 3: Tests + formatting
|
||||
|
||||
- [x] Add/update feature tests for:
|
||||
- only one Spec 332 directory exists
|
||||
- Step 1 full Product Process Flow
|
||||
- Step 1 usable source gate truthfulness
|
||||
- Step 2 compact safety status
|
||||
- Step 2 explicit resolver mode and blocked progression
|
||||
- Step 2 empty group-picker productized empty state
|
||||
- Step 3 blocked progression
|
||||
- Step 4 truthful preview copy
|
||||
- Restore Proof persistence
|
||||
- diagnostics collapsed
|
||||
- no false recovery-proof claims
|
||||
- [x] Add/update browser smoke for Step 1, Step 2 default, Step 2 resolver, Step 2 empty picker, Step 3 blocked, and Step 4 preview.
|
||||
- [x] Run targeted Spec 332 and Restore Run create wizard tests.
|
||||
- [x] Run browser smoke.
|
||||
- [x] Run `pint --dirty`.
|
||||
- [x] Run `git diff --check`.
|
||||
Loading…
Reference in New Issue
Block a user