Compare commits
20 Commits
dev
...
feat/011-r
| Author | SHA1 | Date | |
|---|---|---|---|
| e19aa09ae0 | |||
|
|
e1cda6a1dc | ||
|
|
44b4a6adf0 | ||
|
|
711e012827 | ||
|
|
9e3c2b3011 | ||
|
|
948af56185 | ||
|
|
26755df017 | ||
|
|
4c9544e9b7 | ||
|
|
5e16c25fca | ||
|
|
a43fef535b | ||
|
|
a58db008f8 | ||
|
|
cd76fa5dd7 | ||
|
|
f32fdfb1e4 | ||
|
|
8ba2aae82e | ||
|
|
2b9b649549 | ||
|
|
7e4c9bb610 | ||
|
|
e7d21e0eb8 | ||
|
|
0795d31abe | ||
|
|
3cf4aa2cf4 | ||
|
|
6844bc1c17 |
@ -6,6 +6,8 @@
|
|||||||
use App\Jobs\BulkPolicyVersionForceDeleteJob;
|
use App\Jobs\BulkPolicyVersionForceDeleteJob;
|
||||||
use App\Jobs\BulkPolicyVersionPruneJob;
|
use App\Jobs\BulkPolicyVersionPruneJob;
|
||||||
use App\Jobs\BulkPolicyVersionRestoreJob;
|
use App\Jobs\BulkPolicyVersionRestoreJob;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\BulkOperationService;
|
||||||
@ -13,6 +15,7 @@
|
|||||||
use App\Services\Intune\PolicyNormalizer;
|
use App\Services\Intune\PolicyNormalizer;
|
||||||
use App\Services\Intune\VersionDiff;
|
use App\Services\Intune\VersionDiff;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\BulkAction;
|
use Filament\Actions\BulkAction;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
@ -183,6 +186,96 @@ public static function table(Table $table): Table
|
|||||||
->url(fn (PolicyVersion $record) => static::getUrl('view', ['record' => $record]))
|
->url(fn (PolicyVersion $record) => static::getUrl('view', ['record' => $record]))
|
||||||
->openUrlInNewTab(false),
|
->openUrlInNewTab(false),
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
|
Actions\Action::make('restore_via_wizard')
|
||||||
|
->label('Restore via Wizard')
|
||||||
|
->icon('heroicon-o-arrow-path-rounded-square')
|
||||||
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
|
||||||
|
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')
|
||||||
|
->action(function (PolicyVersion $record) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant || $record->tenant_id !== $tenant->id) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Policy version belongs to a different tenant')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$policy = $record->policy;
|
||||||
|
|
||||||
|
if (! $policy) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Policy could not be found for this version')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupSet = BackupSet::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => sprintf(
|
||||||
|
'Policy Version Restore • %s • v%d',
|
||||||
|
$policy->display_name,
|
||||||
|
$record->version_number
|
||||||
|
),
|
||||||
|
'created_by' => $user?->email,
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 1,
|
||||||
|
'completed_at' => CarbonImmutable::now(),
|
||||||
|
'metadata' => [
|
||||||
|
'source' => 'policy_version',
|
||||||
|
'policy_version_id' => $record->id,
|
||||||
|
'policy_version_number' => $record->version_number,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$scopeTags = is_array($record->scope_tags) ? $record->scope_tags : [];
|
||||||
|
$scopeTagIds = $scopeTags['ids'] ?? null;
|
||||||
|
$scopeTagNames = $scopeTags['names'] ?? null;
|
||||||
|
|
||||||
|
$backupItemMetadata = [
|
||||||
|
'source' => 'policy_version',
|
||||||
|
'display_name' => $policy->display_name,
|
||||||
|
'policy_version_id' => $record->id,
|
||||||
|
'policy_version_number' => $record->version_number,
|
||||||
|
'version_captured_at' => $record->captured_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (is_array($scopeTagIds) && $scopeTagIds !== []) {
|
||||||
|
$backupItemMetadata['scope_tag_ids'] = $scopeTagIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($scopeTagNames) && $scopeTagNames !== []) {
|
||||||
|
$backupItemMetadata['scope_tag_names'] = $scopeTagNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupItem = BackupItem::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'policy_version_id' => $record->id,
|
||||||
|
'policy_identifier' => $policy->external_id,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $record->captured_at ?? CarbonImmutable::now(),
|
||||||
|
'payload' => $record->snapshot ?? [],
|
||||||
|
'metadata' => $backupItemMetadata,
|
||||||
|
'assignments' => $record->assignments,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->to(RestoreRunResource::getUrl('create', [
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'scope_mode' => 'selected',
|
||||||
|
'backup_item_ids' => [$backupItem->id],
|
||||||
|
]));
|
||||||
|
}),
|
||||||
Actions\Action::make('archive')
|
Actions\Action::make('archive')
|
||||||
->label('Archive')
|
->label('Archive')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Jobs\BulkRestoreRunDeleteJob;
|
use App\Jobs\BulkRestoreRunDeleteJob;
|
||||||
use App\Jobs\BulkRestoreRunForceDeleteJob;
|
use App\Jobs\BulkRestoreRunForceDeleteJob;
|
||||||
use App\Jobs\BulkRestoreRunRestoreJob;
|
use App\Jobs\BulkRestoreRunRestoreJob;
|
||||||
|
use App\Jobs\ExecuteRestoreRunJob;
|
||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
@ -13,7 +14,11 @@
|
|||||||
use App\Services\BulkOperationService;
|
use App\Services\BulkOperationService;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Graph\GroupResolver;
|
use App\Services\Graph\GroupResolver;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use App\Services\Intune\RestoreDiffGenerator;
|
||||||
|
use App\Services\Intune\RestoreRiskChecker;
|
||||||
use App\Services\Intune\RestoreService;
|
use App\Services\Intune\RestoreService;
|
||||||
|
use App\Support\RestoreRunStatus;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
@ -26,13 +31,16 @@
|
|||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Components\Utilities\Get;
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
use Filament\Schemas\Components\Utilities\Set;
|
use Filament\Schemas\Components\Utilities\Set;
|
||||||
|
use Filament\Schemas\Components\Wizard\Step;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Contracts\HasTable;
|
use Filament\Tables\Contracts\HasTable;
|
||||||
use Filament\Tables\Filters\TrashedFilter;
|
use Filament\Tables\Filters\TrashedFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class RestoreRunResource extends Resource
|
class RestoreRunResource extends Resource
|
||||||
@ -69,8 +77,10 @@ public static function form(Schema $schema): Schema
|
|||||||
})
|
})
|
||||||
->reactive()
|
->reactive()
|
||||||
->afterStateUpdated(function (Set $set): void {
|
->afterStateUpdated(function (Set $set): void {
|
||||||
$set('backup_item_ids', []);
|
$set('scope_mode', 'all');
|
||||||
|
$set('backup_item_ids', null);
|
||||||
$set('group_mapping', []);
|
$set('group_mapping', []);
|
||||||
|
$set('is_dry_run', true);
|
||||||
})
|
})
|
||||||
->required(),
|
->required(),
|
||||||
Forms\Components\CheckboxList::make('backup_item_ids')
|
Forms\Components\CheckboxList::make('backup_item_ids')
|
||||||
@ -137,6 +147,491 @@ public static function form(Schema $schema): Schema
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Step>
|
||||||
|
*/
|
||||||
|
public static function getWizardSteps(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Step::make('Select Backup Set')
|
||||||
|
->description('What are we restoring from?')
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Select::make('backup_set_id')
|
||||||
|
->label('Backup set')
|
||||||
|
->options(function () {
|
||||||
|
$tenantId = Tenant::current()->getKey();
|
||||||
|
|
||||||
|
return BackupSet::query()
|
||||||
|
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->get()
|
||||||
|
->mapWithKeys(function (BackupSet $set) {
|
||||||
|
$label = sprintf(
|
||||||
|
'%s • %s items • %s',
|
||||||
|
$set->name,
|
||||||
|
$set->item_count ?? 0,
|
||||||
|
optional($set->created_at)->format('Y-m-d H:i')
|
||||||
|
);
|
||||||
|
|
||||||
|
return [$set->id => $label];
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->reactive()
|
||||||
|
->afterStateUpdated(function (Set $set, Get $get): void {
|
||||||
|
$set('scope_mode', 'all');
|
||||||
|
$set('backup_item_ids', null);
|
||||||
|
$set('group_mapping', static::groupMappingPlaceholders(
|
||||||
|
backupSetId: $get('backup_set_id'),
|
||||||
|
scopeMode: 'all',
|
||||||
|
selectedItemIds: null,
|
||||||
|
tenant: Tenant::current(),
|
||||||
|
));
|
||||||
|
$set('is_dry_run', true);
|
||||||
|
$set('acknowledged_impact', false);
|
||||||
|
$set('tenant_confirm', null);
|
||||||
|
$set('check_summary', null);
|
||||||
|
$set('check_results', []);
|
||||||
|
$set('checks_ran_at', null);
|
||||||
|
$set('preview_summary', null);
|
||||||
|
$set('preview_diffs', []);
|
||||||
|
$set('preview_ran_at', null);
|
||||||
|
})
|
||||||
|
->required(),
|
||||||
|
]),
|
||||||
|
Step::make('Define Restore Scope')
|
||||||
|
->description('What exactly should be restored?')
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Radio::make('scope_mode')
|
||||||
|
->label('Scope')
|
||||||
|
->options([
|
||||||
|
'all' => 'All items (default)',
|
||||||
|
'selected' => 'Selected items only',
|
||||||
|
])
|
||||||
|
->default('all')
|
||||||
|
->reactive()
|
||||||
|
->afterStateUpdated(function (Set $set, Get $get, $state): void {
|
||||||
|
$backupSetId = $get('backup_set_id');
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$set('is_dry_run', true);
|
||||||
|
$set('acknowledged_impact', false);
|
||||||
|
$set('tenant_confirm', null);
|
||||||
|
$set('check_summary', null);
|
||||||
|
$set('check_results', []);
|
||||||
|
$set('checks_ran_at', null);
|
||||||
|
$set('preview_summary', null);
|
||||||
|
$set('preview_diffs', []);
|
||||||
|
$set('preview_ran_at', null);
|
||||||
|
|
||||||
|
if ($state === 'all') {
|
||||||
|
$set('backup_item_ids', null);
|
||||||
|
$set('group_mapping', static::groupMappingPlaceholders(
|
||||||
|
backupSetId: $backupSetId,
|
||||||
|
scopeMode: 'all',
|
||||||
|
selectedItemIds: null,
|
||||||
|
tenant: $tenant,
|
||||||
|
));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$set('group_mapping', []);
|
||||||
|
$set('backup_item_ids', []);
|
||||||
|
})
|
||||||
|
->required(),
|
||||||
|
Forms\Components\Select::make('backup_item_ids')
|
||||||
|
->label('Items to restore')
|
||||||
|
->multiple()
|
||||||
|
->searchable()
|
||||||
|
->searchValues()
|
||||||
|
->searchDebounce(400)
|
||||||
|
->optionsLimit(300)
|
||||||
|
->options(fn (Get $get) => static::restoreItemGroupedOptions($get('backup_set_id')))
|
||||||
|
->reactive()
|
||||||
|
->afterStateUpdated(function (Set $set, Get $get): void {
|
||||||
|
$backupSetId = $get('backup_set_id');
|
||||||
|
$selectedItemIds = $get('backup_item_ids');
|
||||||
|
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
$set('group_mapping', static::groupMappingPlaceholders(
|
||||||
|
backupSetId: $backupSetId,
|
||||||
|
scopeMode: 'selected',
|
||||||
|
selectedItemIds: $selectedItemIds,
|
||||||
|
tenant: $tenant,
|
||||||
|
));
|
||||||
|
$set('is_dry_run', true);
|
||||||
|
$set('acknowledged_impact', false);
|
||||||
|
$set('tenant_confirm', null);
|
||||||
|
$set('check_summary', null);
|
||||||
|
$set('check_results', []);
|
||||||
|
$set('checks_ran_at', null);
|
||||||
|
$set('preview_summary', null);
|
||||||
|
$set('preview_diffs', []);
|
||||||
|
$set('preview_ran_at', null);
|
||||||
|
})
|
||||||
|
->visible(fn (Get $get): bool => $get('scope_mode') === 'selected')
|
||||||
|
->required(fn (Get $get): bool => $get('scope_mode') === 'selected')
|
||||||
|
->hintActions([
|
||||||
|
Actions\Action::make('select_all_backup_items')
|
||||||
|
->label('Select all')
|
||||||
|
->icon('heroicon-o-check')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (Get $get): bool => filled($get('backup_set_id')) && $get('scope_mode') === 'selected')
|
||||||
|
->action(function (Get $get, Set $set): void {
|
||||||
|
$groupedOptions = static::restoreItemGroupedOptions($get('backup_set_id'));
|
||||||
|
|
||||||
|
$allItemIds = [];
|
||||||
|
|
||||||
|
foreach ($groupedOptions as $options) {
|
||||||
|
$allItemIds = array_merge($allItemIds, array_keys($options));
|
||||||
|
}
|
||||||
|
|
||||||
|
$set('backup_item_ids', array_values($allItemIds), shouldCallUpdatedHooks: true);
|
||||||
|
}),
|
||||||
|
Actions\Action::make('clear_backup_items')
|
||||||
|
->label('Clear')
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (Get $get): bool => $get('scope_mode') === 'selected')
|
||||||
|
->action(fn (Set $set) => $set('backup_item_ids', [], shouldCallUpdatedHooks: true)),
|
||||||
|
])
|
||||||
|
->helperText('Search by name or ID. Include foundations (scope tags, assignment filters) with policies to re-map IDs. Options are grouped by category, type, and platform.'),
|
||||||
|
Section::make('Group mapping')
|
||||||
|
->description('Some source groups do not exist in the target tenant. Map them or choose Skip.')
|
||||||
|
->schema(function (Get $get): array {
|
||||||
|
$backupSetId = $get('backup_set_id');
|
||||||
|
$scopeMode = $get('scope_mode') ?? 'all';
|
||||||
|
$selectedItemIds = $get('backup_item_ids');
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant || ! $backupSetId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
|
||||||
|
|
||||||
|
if ($scopeMode === 'selected' && ($selectedItemIds === null || $selectedItemIds === [])) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$unresolved = static::unresolvedGroups(
|
||||||
|
backupSetId: $backupSetId,
|
||||||
|
selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null,
|
||||||
|
tenant: $tenant
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_map(function (array $group) use ($tenant): Forms\Components\Select {
|
||||||
|
$groupId = $group['id'];
|
||||||
|
$label = $group['label'];
|
||||||
|
|
||||||
|
return Forms\Components\Select::make("group_mapping.{$groupId}")
|
||||||
|
->label($label)
|
||||||
|
->options([
|
||||||
|
'SKIP' => 'Skip assignment',
|
||||||
|
])
|
||||||
|
->searchable()
|
||||||
|
->getSearchResultsUsing(fn (string $search) => static::targetGroupOptions($tenant, $search))
|
||||||
|
->getOptionLabelUsing(fn (?string $value) => static::resolveTargetGroupLabel($tenant, $value))
|
||||||
|
->reactive()
|
||||||
|
->afterStateUpdated(function (Set $set): void {
|
||||||
|
$set('check_summary', null);
|
||||||
|
$set('check_results', []);
|
||||||
|
$set('checks_ran_at', null);
|
||||||
|
$set('preview_summary', null);
|
||||||
|
$set('preview_diffs', []);
|
||||||
|
$set('preview_ran_at', null);
|
||||||
|
})
|
||||||
|
->helperText('Choose a target group or select Skip.');
|
||||||
|
}, $unresolved);
|
||||||
|
})
|
||||||
|
->visible(function (Get $get): bool {
|
||||||
|
$backupSetId = $get('backup_set_id');
|
||||||
|
$scopeMode = $get('scope_mode') ?? 'all';
|
||||||
|
$selectedItemIds = $get('backup_item_ids');
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant || ! $backupSetId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
|
||||||
|
|
||||||
|
if ($scopeMode === 'selected' && ($selectedItemIds === null || $selectedItemIds === [])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::unresolvedGroups(
|
||||||
|
backupSetId: $backupSetId,
|
||||||
|
selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null,
|
||||||
|
tenant: $tenant
|
||||||
|
) !== [];
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
Step::make('Safety & Conflict Checks')
|
||||||
|
->description('Is this dangerous?')
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Hidden::make('check_summary')
|
||||||
|
->default(null),
|
||||||
|
Forms\Components\Hidden::make('checks_ran_at')
|
||||||
|
->default(null),
|
||||||
|
Forms\Components\ViewField::make('check_results')
|
||||||
|
->label('Checks')
|
||||||
|
->default([])
|
||||||
|
->view('filament.forms.components.restore-run-checks')
|
||||||
|
->viewData(fn (Get $get): array => [
|
||||||
|
'summary' => $get('check_summary'),
|
||||||
|
'ranAt' => $get('checks_ran_at'),
|
||||||
|
])
|
||||||
|
->hintActions([
|
||||||
|
Actions\Action::make('run_restore_checks')
|
||||||
|
->label('Run checks')
|
||||||
|
->icon('heroicon-o-shield-check')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (Get $get): bool => filled($get('backup_set_id')))
|
||||||
|
->action(function (Get $get, Set $set): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupSetId = $get('backup_set_id');
|
||||||
|
|
||||||
|
if (! $backupSetId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupSet = BackupSet::find($backupSetId);
|
||||||
|
|
||||||
|
if (! $backupSet || $backupSet->tenant_id !== $tenant->id) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Unable to run checks')
|
||||||
|
->body('Backup set is not available for the active tenant.')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scopeMode = $get('scope_mode') ?? 'all';
|
||||||
|
$selectedItemIds = ($scopeMode === 'selected')
|
||||||
|
? ($get('backup_item_ids') ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
|
||||||
|
|
||||||
|
$groupMapping = static::normalizeGroupMapping($get('group_mapping'));
|
||||||
|
|
||||||
|
$checker = app(RestoreRiskChecker::class);
|
||||||
|
$outcome = $checker->check(
|
||||||
|
tenant: $tenant,
|
||||||
|
backupSet: $backupSet,
|
||||||
|
selectedItemIds: $selectedItemIds,
|
||||||
|
groupMapping: $groupMapping,
|
||||||
|
);
|
||||||
|
|
||||||
|
$set('check_summary', $outcome['summary'] ?? [], shouldCallUpdatedHooks: true);
|
||||||
|
$set('check_results', $outcome['results'] ?? [], shouldCallUpdatedHooks: true);
|
||||||
|
$set('checks_ran_at', now()->toIso8601String(), shouldCallUpdatedHooks: true);
|
||||||
|
|
||||||
|
$summary = $outcome['summary'] ?? [];
|
||||||
|
$blockers = (int) ($summary['blocking'] ?? 0);
|
||||||
|
$warnings = (int) ($summary['warning'] ?? 0);
|
||||||
|
|
||||||
|
if ($blockers > 0) {
|
||||||
|
$set('is_dry_run', true, shouldCallUpdatedHooks: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Safety checks completed')
|
||||||
|
->body("Blocking: {$blockers} • Warnings: {$warnings}")
|
||||||
|
->status($blockers > 0 ? 'danger' : ($warnings > 0 ? 'warning' : 'success'))
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
Actions\Action::make('clear_restore_checks')
|
||||||
|
->label('Clear')
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (Get $get): bool => filled($get('check_results')) || filled($get('check_summary')))
|
||||||
|
->action(function (Set $set): void {
|
||||||
|
$set('is_dry_run', true, shouldCallUpdatedHooks: true);
|
||||||
|
$set('acknowledged_impact', false, shouldCallUpdatedHooks: true);
|
||||||
|
$set('tenant_confirm', null, shouldCallUpdatedHooks: true);
|
||||||
|
$set('check_summary', null, shouldCallUpdatedHooks: true);
|
||||||
|
$set('check_results', [], shouldCallUpdatedHooks: true);
|
||||||
|
$set('checks_ran_at', null, shouldCallUpdatedHooks: true);
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->helperText('Run checks after defining scope and mapping missing groups.'),
|
||||||
|
]),
|
||||||
|
Step::make('Preview')
|
||||||
|
->description('Dry-run preview')
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Hidden::make('preview_summary')
|
||||||
|
->default(null),
|
||||||
|
Forms\Components\Hidden::make('preview_ran_at')
|
||||||
|
->default(null)
|
||||||
|
->required(),
|
||||||
|
Forms\Components\ViewField::make('preview_diffs')
|
||||||
|
->label('Preview')
|
||||||
|
->default([])
|
||||||
|
->view('filament.forms.components.restore-run-preview')
|
||||||
|
->viewData(fn (Get $get): array => [
|
||||||
|
'summary' => $get('preview_summary'),
|
||||||
|
'ranAt' => $get('preview_ran_at'),
|
||||||
|
])
|
||||||
|
->hintActions([
|
||||||
|
Actions\Action::make('run_restore_preview')
|
||||||
|
->label('Generate preview')
|
||||||
|
->icon('heroicon-o-eye')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (Get $get): bool => filled($get('backup_set_id')))
|
||||||
|
->action(function (Get $get, Set $set): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupSetId = $get('backup_set_id');
|
||||||
|
|
||||||
|
if (! $backupSetId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupSet = BackupSet::find($backupSetId);
|
||||||
|
|
||||||
|
if (! $backupSet || $backupSet->tenant_id !== $tenant->id) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Unable to generate preview')
|
||||||
|
->body('Backup set is not available for the active tenant.')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scopeMode = $get('scope_mode') ?? 'all';
|
||||||
|
$selectedItemIds = ($scopeMode === 'selected')
|
||||||
|
? ($get('backup_item_ids') ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
|
||||||
|
|
||||||
|
$generator = app(RestoreDiffGenerator::class);
|
||||||
|
$outcome = $generator->generate(
|
||||||
|
tenant: $tenant,
|
||||||
|
backupSet: $backupSet,
|
||||||
|
selectedItemIds: $selectedItemIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
$summary = $outcome['summary'] ?? [];
|
||||||
|
$diffs = $outcome['diffs'] ?? [];
|
||||||
|
|
||||||
|
$set('preview_summary', $summary, shouldCallUpdatedHooks: true);
|
||||||
|
$set('preview_diffs', $diffs, shouldCallUpdatedHooks: true);
|
||||||
|
$set('preview_ran_at', $summary['generated_at'] ?? now()->toIso8601String(), shouldCallUpdatedHooks: true);
|
||||||
|
|
||||||
|
$policiesChanged = (int) ($summary['policies_changed'] ?? 0);
|
||||||
|
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Preview generated')
|
||||||
|
->body("Policies: {$policiesChanged}/{$policiesTotal} changed")
|
||||||
|
->status($policiesChanged > 0 ? 'warning' : 'success')
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
Actions\Action::make('clear_restore_preview')
|
||||||
|
->label('Clear')
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (Get $get): bool => filled($get('preview_diffs')) || filled($get('preview_summary')))
|
||||||
|
->action(function (Set $set): void {
|
||||||
|
$set('is_dry_run', true, shouldCallUpdatedHooks: true);
|
||||||
|
$set('acknowledged_impact', false, shouldCallUpdatedHooks: true);
|
||||||
|
$set('tenant_confirm', null, shouldCallUpdatedHooks: true);
|
||||||
|
$set('preview_summary', null, shouldCallUpdatedHooks: true);
|
||||||
|
$set('preview_diffs', [], shouldCallUpdatedHooks: true);
|
||||||
|
$set('preview_ran_at', null, shouldCallUpdatedHooks: true);
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->helperText('Generate a normalized diff preview before creating the dry-run restore.'),
|
||||||
|
]),
|
||||||
|
Step::make('Confirm & Execute')
|
||||||
|
->description('Point of no return')
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Placeholder::make('confirm_environment')
|
||||||
|
->label('Environment')
|
||||||
|
->content(fn (): string => app()->environment('production') ? 'prod' : 'test'),
|
||||||
|
Forms\Components\Placeholder::make('confirm_tenant_label')
|
||||||
|
->label('Tenant hard-confirm label')
|
||||||
|
->content(function (): string {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
||||||
|
|
||||||
|
return (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
|
||||||
|
}),
|
||||||
|
Forms\Components\Toggle::make('is_dry_run')
|
||||||
|
->label('Preview only (dry-run)')
|
||||||
|
->default(true)
|
||||||
|
->reactive()
|
||||||
|
->disabled(function (Get $get): bool {
|
||||||
|
if (! filled($get('checks_ran_at'))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = $get('check_summary');
|
||||||
|
|
||||||
|
if (! is_array($summary)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) ($summary['blocking'] ?? 0) > 0;
|
||||||
|
})
|
||||||
|
->helperText('Turn OFF to queue a real execution. Execution requires checks + preview + confirmations.'),
|
||||||
|
Forms\Components\Checkbox::make('acknowledged_impact')
|
||||||
|
->label('I reviewed the impact (checks + preview)')
|
||||||
|
->accepted()
|
||||||
|
->visible(fn (Get $get): bool => $get('is_dry_run') === false),
|
||||||
|
Forms\Components\TextInput::make('tenant_confirm')
|
||||||
|
->label('Type the tenant label to confirm execution')
|
||||||
|
->required(fn (Get $get): bool => $get('is_dry_run') === false)
|
||||||
|
->visible(fn (Get $get): bool => $get('is_dry_run') === false)
|
||||||
|
->in(function (): array {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
||||||
|
|
||||||
|
return [(string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey())];
|
||||||
|
})
|
||||||
|
->validationMessages([
|
||||||
|
'in' => 'Tenant hard-confirm does not match.',
|
||||||
|
])
|
||||||
|
->helperText(function (): string {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
||||||
|
$expected = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
|
||||||
|
|
||||||
|
return "Type: {$expected}";
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
@ -533,11 +1028,72 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static $cache = [];
|
$cacheKey = sprintf('restore_run_item_options:%s:%s', $tenant->getKey(), $backupSetId);
|
||||||
$cacheKey = $tenant->getKey().':'.$backupSetId;
|
|
||||||
|
|
||||||
if (isset($cache[$cacheKey])) {
|
return Cache::store('array')->rememberForever($cacheKey, function () use ($backupSetId, $tenant): array {
|
||||||
return $cache[$cacheKey];
|
$items = BackupItem::query()
|
||||||
|
->where('backup_set_id', $backupSetId)
|
||||||
|
->whereHas('backupSet', fn ($query) => $query->where('tenant_id', $tenant->getKey()))
|
||||||
|
->where(function ($query) {
|
||||||
|
$query->whereNull('policy_id')
|
||||||
|
->orWhereDoesntHave('policy')
|
||||||
|
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
|
||||||
|
})
|
||||||
|
->with(['policy:id,display_name', 'policyVersion:id,version_number,captured_at'])
|
||||||
|
->get()
|
||||||
|
->sortBy(function (BackupItem $item) {
|
||||||
|
$meta = static::typeMeta($item->policy_type);
|
||||||
|
$category = $meta['category'] ?? 'Policies';
|
||||||
|
$categoryKey = $category === 'Foundations' ? 'zz-'.$category : $category;
|
||||||
|
$name = strtolower($item->resolvedDisplayName());
|
||||||
|
|
||||||
|
return strtolower($categoryKey.'-'.$name);
|
||||||
|
});
|
||||||
|
|
||||||
|
$options = [];
|
||||||
|
$descriptions = [];
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$meta = static::typeMeta($item->policy_type);
|
||||||
|
$typeLabel = $meta['label'] ?? $item->policy_type;
|
||||||
|
$category = $meta['category'] ?? 'Policies';
|
||||||
|
$restore = $meta['restore'] ?? 'enabled';
|
||||||
|
$platform = $item->platform ?? $meta['platform'] ?? null;
|
||||||
|
$displayName = $item->resolvedDisplayName();
|
||||||
|
$identifier = $item->policy_identifier ?? null;
|
||||||
|
$versionNumber = $item->policyVersion?->version_number;
|
||||||
|
|
||||||
|
$options[$item->id] = $displayName;
|
||||||
|
|
||||||
|
$parts = array_filter([
|
||||||
|
$category,
|
||||||
|
$typeLabel,
|
||||||
|
$platform,
|
||||||
|
"restore: {$restore}",
|
||||||
|
$versionNumber ? "version: {$versionNumber}" : null,
|
||||||
|
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
|
||||||
|
$identifier ? 'id: '.Str::limit($identifier, 24, '...') : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$descriptions[$item->id] = implode(' • ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'options' => $options,
|
||||||
|
'descriptions' => $descriptions,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int|string, string>>
|
||||||
|
*/
|
||||||
|
private static function restoreItemGroupedOptions(?int $backupSetId): array
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant || ! $backupSetId) {
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$items = BackupItem::query()
|
$items = BackupItem::query()
|
||||||
@ -548,49 +1104,40 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
|||||||
->orWhereDoesntHave('policy')
|
->orWhereDoesntHave('policy')
|
||||||
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
|
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
|
||||||
})
|
})
|
||||||
->with(['policy:id,display_name', 'policyVersion:id,version_number,captured_at'])
|
->with(['policy:id,display_name'])
|
||||||
->get()
|
->get()
|
||||||
->sortBy(function (BackupItem $item) {
|
->sortBy(function (BackupItem $item) {
|
||||||
$meta = static::typeMeta($item->policy_type);
|
$meta = static::typeMeta($item->policy_type);
|
||||||
$category = $meta['category'] ?? 'Policies';
|
$category = $meta['category'] ?? 'Policies';
|
||||||
$categoryKey = $category === 'Foundations' ? 'zz-'.$category : $category;
|
$categoryKey = $category === 'Foundations' ? 'zz-'.$category : $category;
|
||||||
|
$typeLabel = $meta['label'] ?? $item->policy_type;
|
||||||
|
$platform = $item->platform ?? $meta['platform'] ?? null;
|
||||||
$name = strtolower($item->resolvedDisplayName());
|
$name = strtolower($item->resolvedDisplayName());
|
||||||
|
|
||||||
return strtolower($categoryKey.'-'.$name);
|
return strtolower($categoryKey.'-'.$typeLabel.'-'.$platform.'-'.$name);
|
||||||
});
|
});
|
||||||
|
|
||||||
$options = [];
|
$groups = [];
|
||||||
$descriptions = [];
|
|
||||||
|
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
$meta = static::typeMeta($item->policy_type);
|
$meta = static::typeMeta($item->policy_type);
|
||||||
$typeLabel = $meta['label'] ?? $item->policy_type;
|
$typeLabel = $meta['label'] ?? $item->policy_type;
|
||||||
$category = $meta['category'] ?? 'Policies';
|
$category = $meta['category'] ?? 'Policies';
|
||||||
$restore = $meta['restore'] ?? 'enabled';
|
$platform = $item->platform ?? $meta['platform'] ?? 'all';
|
||||||
$platform = $item->platform ?? $meta['platform'] ?? null;
|
$restoreMode = $meta['restore'] ?? 'enabled';
|
||||||
$displayName = $item->resolvedDisplayName();
|
|
||||||
$identifier = $item->policy_identifier ?? null;
|
|
||||||
$versionNumber = $item->policyVersion?->version_number;
|
|
||||||
|
|
||||||
$options[$item->id] = $displayName;
|
$groupLabel = implode(' • ', array_filter([
|
||||||
|
|
||||||
$parts = array_filter([
|
|
||||||
$category,
|
$category,
|
||||||
$typeLabel,
|
$typeLabel,
|
||||||
$platform,
|
$platform,
|
||||||
"restore: {$restore}",
|
$restoreMode === 'preview-only' ? 'preview-only' : null,
|
||||||
$versionNumber ? "version: {$versionNumber}" : null,
|
]));
|
||||||
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
|
|
||||||
$identifier ? 'id: '.Str::limit($identifier, 24, '...') : null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$descriptions[$item->id] = implode(' • ', $parts);
|
$groups[$groupLabel] ??= [];
|
||||||
|
$groups[$groupLabel][$item->id] = $item->resolvedDisplayName();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $cache[$cacheKey] = [
|
return $groups;
|
||||||
'options' => $options,
|
|
||||||
'descriptions' => $descriptions,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function createRestoreRun(array $data): RestoreRun
|
public static function createRestoreRun(array $data): RestoreRun
|
||||||
@ -608,15 +1155,170 @@ public static function createRestoreRun(array $data): RestoreRun
|
|||||||
/** @var RestoreService $service */
|
/** @var RestoreService $service */
|
||||||
$service = app(RestoreService::class);
|
$service = app(RestoreService::class);
|
||||||
|
|
||||||
return $service->execute(
|
$scopeMode = $data['scope_mode'] ?? 'all';
|
||||||
|
$selectedItemIds = ($scopeMode === 'selected') ? ($data['backup_item_ids'] ?? null) : null;
|
||||||
|
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
|
||||||
|
|
||||||
|
$actorEmail = auth()->user()?->email;
|
||||||
|
$actorName = auth()->user()?->name;
|
||||||
|
$isDryRun = (bool) ($data['is_dry_run'] ?? true);
|
||||||
|
$groupMapping = static::normalizeGroupMapping($data['group_mapping'] ?? null);
|
||||||
|
|
||||||
|
$checkSummary = $data['check_summary'] ?? null;
|
||||||
|
$checkResults = $data['check_results'] ?? null;
|
||||||
|
$checksRanAt = $data['checks_ran_at'] ?? null;
|
||||||
|
$previewSummary = $data['preview_summary'] ?? null;
|
||||||
|
$previewDiffs = $data['preview_diffs'] ?? null;
|
||||||
|
$previewRanAt = $data['preview_ran_at'] ?? null;
|
||||||
|
|
||||||
|
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
||||||
|
$highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
|
||||||
|
|
||||||
|
if (! $isDryRun) {
|
||||||
|
if (! is_array($checkSummary) || ! filled($checksRanAt)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'check_summary' => 'Run safety checks before executing.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$blocking = (int) ($checkSummary['blocking'] ?? 0);
|
||||||
|
$hasBlockers = (bool) ($checkSummary['has_blockers'] ?? ($blocking > 0));
|
||||||
|
|
||||||
|
if ($blocking > 0 || $hasBlockers) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'check_summary' => 'Blocking checks must be resolved before executing.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! filled($previewRanAt)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'preview_ran_at' => 'Generate preview before executing.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! (bool) ($data['acknowledged_impact'] ?? false)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'acknowledged_impact' => 'Please acknowledge that you reviewed the impact.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantConfirm = $data['tenant_confirm'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($tenantConfirm) || $tenantConfirm !== $highlanderLabel) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'tenant_confirm' => 'Tenant hard-confirm does not match.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isDryRun) {
|
||||||
|
$restoreRun = $service->execute(
|
||||||
|
tenant: $tenant,
|
||||||
|
backupSet: $backupSet,
|
||||||
|
selectedItemIds: $selectedItemIds,
|
||||||
|
dryRun: true,
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
groupMapping: $groupMapping,
|
||||||
|
);
|
||||||
|
|
||||||
|
$metadata = $restoreRun->metadata ?? [];
|
||||||
|
|
||||||
|
if (is_array($checkSummary)) {
|
||||||
|
$metadata['check_summary'] = $checkSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($checkResults)) {
|
||||||
|
$metadata['check_results'] = $checkResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($checksRanAt) && $checksRanAt !== '') {
|
||||||
|
$metadata['checks_ran_at'] = $checksRanAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($previewSummary)) {
|
||||||
|
$metadata['preview_summary'] = $previewSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($previewDiffs)) {
|
||||||
|
$metadata['preview_diffs'] = $previewDiffs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($previewRanAt) && $previewRanAt !== '') {
|
||||||
|
$metadata['preview_ran_at'] = $previewRanAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
$restoreRun->update(['metadata' => $metadata]);
|
||||||
|
|
||||||
|
return $restoreRun->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
$preview = $service->preview($tenant, $backupSet, $selectedItemIds);
|
||||||
|
|
||||||
|
$metadata = [
|
||||||
|
'scope_mode' => $selectedItemIds === null ? 'all' : 'selected',
|
||||||
|
'environment' => app()->environment('production') ? 'prod' : 'test',
|
||||||
|
'highlander_label' => $highlanderLabel,
|
||||||
|
'confirmed_at' => now()->toIso8601String(),
|
||||||
|
'confirmed_by' => $actorEmail,
|
||||||
|
'confirmed_by_name' => $actorName,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (is_array($checkSummary)) {
|
||||||
|
$metadata['check_summary'] = $checkSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($checkResults)) {
|
||||||
|
$metadata['check_results'] = $checkResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($checksRanAt) && $checksRanAt !== '') {
|
||||||
|
$metadata['checks_ran_at'] = $checksRanAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($previewSummary)) {
|
||||||
|
$metadata['preview_summary'] = $previewSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($previewDiffs)) {
|
||||||
|
$metadata['preview_diffs'] = $previewDiffs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($previewRanAt) && $previewRanAt !== '') {
|
||||||
|
$metadata['preview_ran_at'] = $previewRanAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
$restoreRun = RestoreRun::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'requested_by' => $actorEmail,
|
||||||
|
'is_dry_run' => false,
|
||||||
|
'status' => RestoreRunStatus::Queued->value,
|
||||||
|
'requested_items' => $selectedItemIds,
|
||||||
|
'preview' => $preview,
|
||||||
|
'metadata' => $metadata,
|
||||||
|
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(AuditLogger::class)->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
backupSet: $backupSet,
|
action: 'restore.queued',
|
||||||
selectedItemIds: $data['backup_item_ids'] ?? null,
|
context: [
|
||||||
dryRun: (bool) ($data['is_dry_run'] ?? true),
|
'metadata' => [
|
||||||
actorEmail: auth()->user()?->email,
|
'restore_run_id' => $restoreRun->id,
|
||||||
actorName: auth()->user()?->name,
|
'backup_set_id' => $backupSet->id,
|
||||||
groupMapping: $data['group_mapping'] ?? [],
|
],
|
||||||
|
],
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
resourceType: 'restore_run',
|
||||||
|
resourceId: (string) $restoreRun->id,
|
||||||
|
status: 'success',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName);
|
||||||
|
|
||||||
|
return $restoreRun->refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -703,6 +1405,111 @@ private static function unresolvedGroups(?int $backupSetId, ?array $selectedItem
|
|||||||
return $unresolved;
|
return $unresolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int>|null $selectedItemIds
|
||||||
|
* @return array<string, string|null>
|
||||||
|
*/
|
||||||
|
private static function groupMappingPlaceholders(?int $backupSetId, string $scopeMode, ?array $selectedItemIds, ?Tenant $tenant): array
|
||||||
|
{
|
||||||
|
if (! $tenant || ! $backupSetId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($scopeMode === 'selected' && ($selectedItemIds === null || $selectedItemIds === [])) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$unresolved = static::unresolvedGroups(
|
||||||
|
backupSetId: $backupSetId,
|
||||||
|
selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null,
|
||||||
|
tenant: $tenant,
|
||||||
|
);
|
||||||
|
|
||||||
|
$placeholders = [];
|
||||||
|
|
||||||
|
foreach ($unresolved as $group) {
|
||||||
|
$groupId = $group['id'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($groupId) || $groupId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$placeholders[$groupId] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $placeholders;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private static function normalizeGroupMapping(mixed $mapping): array
|
||||||
|
{
|
||||||
|
if ($mapping instanceof \Illuminate\Contracts\Support\Arrayable) {
|
||||||
|
$mapping = $mapping->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mapping instanceof \stdClass) {
|
||||||
|
$mapping = (array) $mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($mapping)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
if (array_key_exists('group_mapping', $mapping)) {
|
||||||
|
$nested = $mapping['group_mapping'];
|
||||||
|
|
||||||
|
if ($nested instanceof \Illuminate\Contracts\Support\Arrayable) {
|
||||||
|
$nested = $nested->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($nested instanceof \stdClass) {
|
||||||
|
$nested = (array) $nested;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($nested)) {
|
||||||
|
$mapping = $nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($mapping as $key => $value) {
|
||||||
|
if (! is_string($key) || $key === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceGroupId = str_starts_with($key, 'group_mapping.')
|
||||||
|
? substr($key, strlen('group_mapping.'))
|
||||||
|
: $key;
|
||||||
|
|
||||||
|
if ($sourceGroupId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value instanceof BackedEnum) {
|
||||||
|
$value = $value->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value) || $value instanceof \stdClass) {
|
||||||
|
$value = (array) $value;
|
||||||
|
$value = $value['value'] ?? $value['id'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value)) {
|
||||||
|
$value = trim($value);
|
||||||
|
$result[$sourceGroupId] = $value !== '' ? $value : null;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[$sourceGroupId] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_filter($result, static fn (?string $value): bool => is_string($value) && $value !== '');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -3,13 +3,118 @@
|
|||||||
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Resources\Pages\Concerns\HasWizard;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class CreateRestoreRun extends CreateRecord
|
class CreateRestoreRun extends CreateRecord
|
||||||
{
|
{
|
||||||
|
use HasWizard;
|
||||||
|
|
||||||
protected static string $resource = RestoreRunResource::class;
|
protected static string $resource = RestoreRunResource::class;
|
||||||
|
|
||||||
|
public function getSteps(): array
|
||||||
|
{
|
||||||
|
return RestoreRunResource::getWizardSteps();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function afterFill(): void
|
||||||
|
{
|
||||||
|
$backupSetIdRaw = request()->query('backup_set_id');
|
||||||
|
|
||||||
|
if (! is_numeric($backupSetIdRaw)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupSetId = (int) $backupSetIdRaw;
|
||||||
|
|
||||||
|
if ($backupSetId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$belongsToTenant = BackupSet::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->whereKey($backupSetId)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $belongsToTenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupItemIds = $this->normalizeBackupItemIds(request()->query('backup_item_ids'));
|
||||||
|
$scopeModeRaw = request()->query('scope_mode');
|
||||||
|
$scopeMode = in_array($scopeModeRaw, ['all', 'selected'], true)
|
||||||
|
? $scopeModeRaw
|
||||||
|
: ($backupItemIds !== [] ? 'selected' : 'all');
|
||||||
|
|
||||||
|
$this->data['backup_set_id'] = $backupSetId;
|
||||||
|
$this->form->callAfterStateUpdated('data.backup_set_id');
|
||||||
|
|
||||||
|
$this->data['scope_mode'] = $scopeMode;
|
||||||
|
$this->form->callAfterStateUpdated('data.scope_mode');
|
||||||
|
|
||||||
|
if ($scopeMode === 'selected') {
|
||||||
|
if ($backupItemIds !== []) {
|
||||||
|
$this->data['backup_item_ids'] = $backupItemIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->form->callAfterStateUpdated('data.backup_item_ids');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int>
|
||||||
|
*/
|
||||||
|
private function normalizeBackupItemIds(mixed $raw): array
|
||||||
|
{
|
||||||
|
if (is_string($raw)) {
|
||||||
|
$raw = array_filter(array_map('trim', explode(',', $raw)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($raw)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$itemIds = [];
|
||||||
|
|
||||||
|
foreach ($raw as $value) {
|
||||||
|
if (is_int($value) && $value > 0) {
|
||||||
|
$itemIds[] = $value;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value) && ctype_digit($value)) {
|
||||||
|
$itemId = (int) $value;
|
||||||
|
|
||||||
|
if ($itemId > 0) {
|
||||||
|
$itemIds[] = $itemId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$itemIds = array_values(array_unique($itemIds));
|
||||||
|
sort($itemIds);
|
||||||
|
|
||||||
|
return $itemIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getSubmitFormAction(): Action
|
||||||
|
{
|
||||||
|
return parent::getSubmitFormAction()
|
||||||
|
->label('Create restore run')
|
||||||
|
->icon('heroicon-o-check-circle');
|
||||||
|
}
|
||||||
|
|
||||||
protected function handleRecordCreation(array $data): Model
|
protected function handleRecordCreation(array $data): Model
|
||||||
{
|
{
|
||||||
return RestoreRunResource::createRestoreRun($data);
|
return RestoreRunResource::createRestoreRun($data);
|
||||||
|
|||||||
134
app/Jobs/ExecuteRestoreRunJob.php
Normal file
134
app/Jobs/ExecuteRestoreRunJob.php
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\RestoreRun;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use App\Services\Intune\RestoreService;
|
||||||
|
use App\Support\RestoreRunStatus;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class ExecuteRestoreRunJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $restoreRunId,
|
||||||
|
public ?string $actorEmail = null,
|
||||||
|
public ?string $actorName = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(RestoreService $restoreService, AuditLogger $auditLogger): void
|
||||||
|
{
|
||||||
|
$restoreRun = RestoreRun::with(['tenant', 'backupSet'])->find($this->restoreRunId);
|
||||||
|
|
||||||
|
if (! $restoreRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($restoreRun->status !== RestoreRunStatus::Queued->value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $restoreRun->tenant;
|
||||||
|
$backupSet = $restoreRun->backupSet;
|
||||||
|
|
||||||
|
if (! $tenant || ! $backupSet || $backupSet->trashed()) {
|
||||||
|
$restoreRun->update([
|
||||||
|
'status' => RestoreRunStatus::Failed->value,
|
||||||
|
'failure_reason' => 'Backup set is archived or unavailable.',
|
||||||
|
'completed_at' => CarbonImmutable::now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($tenant) {
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'restore.failed',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'restore_run_id' => $restoreRun->id,
|
||||||
|
'backup_set_id' => $restoreRun->backup_set_id,
|
||||||
|
'reason' => 'Backup set is archived or unavailable.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorEmail: $this->actorEmail,
|
||||||
|
actorName: $this->actorName,
|
||||||
|
resourceType: 'restore_run',
|
||||||
|
resourceId: (string) $restoreRun->id,
|
||||||
|
status: 'failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$restoreRun->update([
|
||||||
|
'status' => RestoreRunStatus::Running->value,
|
||||||
|
'started_at' => CarbonImmutable::now(),
|
||||||
|
'failure_reason' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'restore.started',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'restore_run_id' => $restoreRun->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorEmail: $this->actorEmail,
|
||||||
|
actorName: $this->actorName,
|
||||||
|
resourceType: 'restore_run',
|
||||||
|
resourceId: (string) $restoreRun->id,
|
||||||
|
status: 'success',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$restoreService->executeForRun(
|
||||||
|
restoreRun: $restoreRun,
|
||||||
|
tenant: $tenant,
|
||||||
|
backupSet: $backupSet,
|
||||||
|
actorEmail: $this->actorEmail,
|
||||||
|
actorName: $this->actorName,
|
||||||
|
);
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$restoreRun->refresh();
|
||||||
|
|
||||||
|
if ($restoreRun->status === RestoreRunStatus::Running->value) {
|
||||||
|
$restoreRun->update([
|
||||||
|
'status' => RestoreRunStatus::Failed->value,
|
||||||
|
'failure_reason' => $throwable->getMessage(),
|
||||||
|
'completed_at' => CarbonImmutable::now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenant) {
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'restore.failed',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'restore_run_id' => $restoreRun->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'reason' => $throwable->getMessage(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorEmail: $this->actorEmail,
|
||||||
|
actorName: $this->actorName,
|
||||||
|
resourceType: 'restore_run',
|
||||||
|
resourceId: (string) $restoreRun->id,
|
||||||
|
status: 'failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $throwable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Support\RestoreRunStatus;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
@ -35,17 +37,30 @@ public function backupSet(): BelongsTo
|
|||||||
return $this->belongsTo(BackupSet::class)->withTrashed();
|
return $this->belongsTo(BackupSet::class)->withTrashed();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeDeletable($query)
|
public function scopeDeletable(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->whereIn('status', ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed']);
|
return $query->whereIn('status', array_map(
|
||||||
|
static fn (RestoreRunStatus $status): string => $status->value,
|
||||||
|
[
|
||||||
|
RestoreRunStatus::Draft,
|
||||||
|
RestoreRunStatus::Scoped,
|
||||||
|
RestoreRunStatus::Checked,
|
||||||
|
RestoreRunStatus::Previewed,
|
||||||
|
RestoreRunStatus::Completed,
|
||||||
|
RestoreRunStatus::Partial,
|
||||||
|
RestoreRunStatus::Failed,
|
||||||
|
RestoreRunStatus::Cancelled,
|
||||||
|
RestoreRunStatus::Aborted,
|
||||||
|
RestoreRunStatus::CompletedWithErrors,
|
||||||
|
]
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isDeletable(): bool
|
public function isDeletable(): bool
|
||||||
{
|
{
|
||||||
$status = strtolower(trim((string) $this->status));
|
$status = RestoreRunStatus::fromString($this->status);
|
||||||
$status = str_replace([' ', '-'], '_', $status);
|
|
||||||
|
|
||||||
return in_array($status, ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed'], true);
|
return $status?->isDeletable() ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group mapping helpers
|
// Group mapping helpers
|
||||||
|
|||||||
248
app/Services/Intune/RestoreDiffGenerator.php
Normal file
248
app/Services/Intune/RestoreDiffGenerator.php
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Intune;
|
||||||
|
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class RestoreDiffGenerator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PolicyNormalizer $policyNormalizer,
|
||||||
|
private readonly VersionDiff $versionDiff,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int>|null $selectedItemIds
|
||||||
|
* @return array{summary: array<string, mixed>, diffs: array<int, array<string, mixed>>}
|
||||||
|
*/
|
||||||
|
public function generate(Tenant $tenant, BackupSet $backupSet, ?array $selectedItemIds = null): array
|
||||||
|
{
|
||||||
|
if ($backupSet->tenant_id !== $tenant->id) {
|
||||||
|
throw new \InvalidArgumentException('Backup set does not belong to the provided tenant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($selectedItemIds === []) {
|
||||||
|
$selectedItemIds = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $this->loadItems($backupSet, $selectedItemIds);
|
||||||
|
$policyItems = $items
|
||||||
|
->reject(fn (BackupItem $item): bool => $item->isFoundation())
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$policyIds = $policyItems
|
||||||
|
->pluck('policy_id')
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$latestVersions = $this->latestVersionsByPolicyId($tenant, $policyIds);
|
||||||
|
|
||||||
|
$maxDetailedDiffs = 25;
|
||||||
|
$maxEntriesPerSection = 200;
|
||||||
|
|
||||||
|
$policiesChanged = 0;
|
||||||
|
$assignmentsChanged = 0;
|
||||||
|
$scopeTagsChanged = 0;
|
||||||
|
|
||||||
|
$diffs = [];
|
||||||
|
$diffsOmitted = 0;
|
||||||
|
|
||||||
|
foreach ($policyItems as $index => $item) {
|
||||||
|
$policyId = $item->policy_id ? (int) $item->policy_id : null;
|
||||||
|
$currentVersion = $policyId ? ($latestVersions[$policyId] ?? null) : null;
|
||||||
|
|
||||||
|
$currentSnapshot = is_array($currentVersion?->snapshot) ? $currentVersion->snapshot : [];
|
||||||
|
$backupSnapshot = is_array($item->payload) ? $item->payload : [];
|
||||||
|
|
||||||
|
$policyType = (string) ($item->policy_type ?? '');
|
||||||
|
$platform = $item->platform;
|
||||||
|
|
||||||
|
$from = $this->policyNormalizer->flattenForDiff($currentSnapshot, $policyType, $platform);
|
||||||
|
$to = $this->policyNormalizer->flattenForDiff($backupSnapshot, $policyType, $platform);
|
||||||
|
|
||||||
|
$diff = $this->versionDiff->compare($from, $to);
|
||||||
|
$summary = $diff['summary'] ?? ['added' => 0, 'removed' => 0, 'changed' => 0];
|
||||||
|
|
||||||
|
$hasPolicyChanges = ((int) ($summary['added'] ?? 0) + (int) ($summary['removed'] ?? 0) + (int) ($summary['changed'] ?? 0)) > 0;
|
||||||
|
|
||||||
|
if ($hasPolicyChanges) {
|
||||||
|
$policiesChanged++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$assignmentDiff = $this->assignmentsChanged($item->assignments, $currentVersion?->assignments);
|
||||||
|
if ($assignmentDiff) {
|
||||||
|
$assignmentsChanged++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scopeTagDiff = $this->scopeTagsChanged($item, $currentVersion);
|
||||||
|
if ($scopeTagDiff) {
|
||||||
|
$scopeTagsChanged++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$diffEntry = [
|
||||||
|
'backup_item_id' => $item->id,
|
||||||
|
'display_name' => $item->resolvedDisplayName(),
|
||||||
|
'policy_identifier' => $item->policy_identifier,
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'platform' => $platform,
|
||||||
|
'action' => $currentVersion ? 'update' : 'create',
|
||||||
|
'diff' => [
|
||||||
|
'summary' => $summary,
|
||||||
|
'added' => [],
|
||||||
|
'removed' => [],
|
||||||
|
'changed' => [],
|
||||||
|
],
|
||||||
|
'assignments_changed' => $assignmentDiff,
|
||||||
|
'scope_tags_changed' => $scopeTagDiff,
|
||||||
|
'diff_omitted' => false,
|
||||||
|
'diff_truncated' => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($index >= $maxDetailedDiffs) {
|
||||||
|
$diffEntry['diff_omitted'] = true;
|
||||||
|
$diffEntry['diff_truncated'] = true;
|
||||||
|
$diffEntry['diff'] = [
|
||||||
|
'summary' => $summary,
|
||||||
|
];
|
||||||
|
$diffsOmitted++;
|
||||||
|
$diffs[] = $diffEntry;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$added = is_array($diff['added'] ?? null) ? $diff['added'] : [];
|
||||||
|
$removed = is_array($diff['removed'] ?? null) ? $diff['removed'] : [];
|
||||||
|
$changed = is_array($diff['changed'] ?? null) ? $diff['changed'] : [];
|
||||||
|
|
||||||
|
$diffEntry['diff_truncated'] = count($added) > $maxEntriesPerSection
|
||||||
|
|| count($removed) > $maxEntriesPerSection
|
||||||
|
|| count($changed) > $maxEntriesPerSection;
|
||||||
|
|
||||||
|
$diffEntry['diff'] = [
|
||||||
|
'summary' => $summary,
|
||||||
|
'added' => array_slice($added, 0, $maxEntriesPerSection, true),
|
||||||
|
'removed' => array_slice($removed, 0, $maxEntriesPerSection, true),
|
||||||
|
'changed' => array_slice($changed, 0, $maxEntriesPerSection, true),
|
||||||
|
];
|
||||||
|
|
||||||
|
$diffs[] = $diffEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summary' => [
|
||||||
|
'generated_at' => CarbonImmutable::now()->toIso8601String(),
|
||||||
|
'policies_total' => $policyItems->count(),
|
||||||
|
'policies_changed' => $policiesChanged,
|
||||||
|
'assignments_changed' => $assignmentsChanged,
|
||||||
|
'scope_tags_changed' => $scopeTagsChanged,
|
||||||
|
'diffs_detailed' => min($policyItems->count(), $maxDetailedDiffs),
|
||||||
|
'diffs_omitted' => $diffsOmitted,
|
||||||
|
'limits' => [
|
||||||
|
'max_detailed_diffs' => $maxDetailedDiffs,
|
||||||
|
'max_entries_per_section' => $maxEntriesPerSection,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'diffs' => $diffs,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int>|null $selectedItemIds
|
||||||
|
* @return Collection<int, BackupItem>
|
||||||
|
*/
|
||||||
|
private function loadItems(BackupSet $backupSet, ?array $selectedItemIds): Collection
|
||||||
|
{
|
||||||
|
$query = $backupSet->items()->getQuery();
|
||||||
|
|
||||||
|
if ($selectedItemIds !== null) {
|
||||||
|
$query->whereIn('id', $selectedItemIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->orderBy('id')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, int> $policyIds
|
||||||
|
* @return array<int, PolicyVersion>
|
||||||
|
*/
|
||||||
|
private function latestVersionsByPolicyId(Tenant $tenant, array $policyIds): array
|
||||||
|
{
|
||||||
|
if ($policyIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$latestVersionsQuery = PolicyVersion::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->whereIn('policy_id', $policyIds)
|
||||||
|
->selectRaw('policy_id, max(version_number) as version_number')
|
||||||
|
->groupBy('policy_id');
|
||||||
|
|
||||||
|
return PolicyVersion::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->joinSub($latestVersionsQuery, 'latest_versions', function ($join): void {
|
||||||
|
$join->on('policy_versions.policy_id', '=', 'latest_versions.policy_id')
|
||||||
|
->on('policy_versions.version_number', '=', 'latest_versions.version_number');
|
||||||
|
})
|
||||||
|
->get()
|
||||||
|
->keyBy('policy_id')
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assignmentsChanged(?array $backupAssignments, ?array $currentAssignments): bool
|
||||||
|
{
|
||||||
|
$backup = $this->normalizeAssignments($backupAssignments);
|
||||||
|
$current = $this->normalizeAssignments($currentAssignments);
|
||||||
|
|
||||||
|
return $backup !== $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function scopeTagsChanged(BackupItem $backupItem, ?PolicyVersion $currentVersion): bool
|
||||||
|
{
|
||||||
|
$backupIds = $backupItem->scope_tag_ids;
|
||||||
|
$backupIds = is_array($backupIds) ? $backupIds : [];
|
||||||
|
$backupIds = array_values(array_filter($backupIds, fn (mixed $id): bool => is_string($id) && $id !== '' && $id !== '0'));
|
||||||
|
sort($backupIds);
|
||||||
|
|
||||||
|
$scopeTags = $currentVersion?->scope_tags;
|
||||||
|
$currentIds = is_array($scopeTags) ? ($scopeTags['ids'] ?? []) : [];
|
||||||
|
$currentIds = is_array($currentIds) ? $currentIds : [];
|
||||||
|
$currentIds = array_values(array_filter($currentIds, fn (mixed $id): bool => is_string($id) && $id !== '' && $id !== '0'));
|
||||||
|
sort($currentIds);
|
||||||
|
|
||||||
|
return $backupIds !== $currentIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function normalizeAssignments(?array $assignments): array
|
||||||
|
{
|
||||||
|
$assignments = is_array($assignments) ? $assignments : [];
|
||||||
|
|
||||||
|
$normalized = [];
|
||||||
|
|
||||||
|
foreach ($assignments as $assignment) {
|
||||||
|
if (! is_array($assignment)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized[] = $assignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($normalized, function (array $a, array $b): int {
|
||||||
|
$left = json_encode($a, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '';
|
||||||
|
$right = json_encode($b, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '';
|
||||||
|
|
||||||
|
return $left <=> $right;
|
||||||
|
});
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
608
app/Services/Intune/RestoreRiskChecker.php
Normal file
608
app/Services/Intune/RestoreRiskChecker.php
Normal file
@ -0,0 +1,608 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Intune;
|
||||||
|
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Graph\GroupResolver;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class RestoreRiskChecker
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly GroupResolver $groupResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int>|null $selectedItemIds
|
||||||
|
* @param array<string, string|null> $groupMapping
|
||||||
|
* @return array{summary: array{blocking: int, warning: int, safe: int, has_blockers: bool}, results: array<int, array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}>}
|
||||||
|
*/
|
||||||
|
public function check(Tenant $tenant, BackupSet $backupSet, ?array $selectedItemIds = null, array $groupMapping = []): array
|
||||||
|
{
|
||||||
|
if ($backupSet->tenant_id !== $tenant->id) {
|
||||||
|
throw new \InvalidArgumentException('Backup set does not belong to the provided tenant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $this->loadItems($backupSet, $selectedItemIds);
|
||||||
|
|
||||||
|
$policyItems = $items
|
||||||
|
->reject(fn (BackupItem $item): bool => $item->isFoundation())
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
$results[] = $this->checkOrphanedGroups($tenant, $policyItems, $groupMapping);
|
||||||
|
$results[] = $this->checkPreviewOnlyPolicies($policyItems);
|
||||||
|
$results[] = $this->checkMissingPolicies($tenant, $policyItems);
|
||||||
|
$results[] = $this->checkStalePolicies($tenant, $policyItems);
|
||||||
|
$results[] = $this->checkMissingScopeTagsInScope($items, $policyItems, $selectedItemIds !== null);
|
||||||
|
|
||||||
|
$results = array_values(array_filter($results));
|
||||||
|
|
||||||
|
$summary = [
|
||||||
|
'blocking' => 0,
|
||||||
|
'warning' => 0,
|
||||||
|
'safe' => 0,
|
||||||
|
'has_blockers' => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($results as $result) {
|
||||||
|
$severity = $result['severity'] ?? 'safe';
|
||||||
|
|
||||||
|
if (! in_array($severity, ['blocking', 'warning', 'safe'], true)) {
|
||||||
|
$severity = 'safe';
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary[$severity]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary['has_blockers'] = $summary['blocking'] > 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summary' => $summary,
|
||||||
|
'results' => $results,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int>|null $selectedItemIds
|
||||||
|
* @return Collection<int, BackupItem>
|
||||||
|
*/
|
||||||
|
private function loadItems(BackupSet $backupSet, ?array $selectedItemIds): Collection
|
||||||
|
{
|
||||||
|
$query = $backupSet->items()->getQuery();
|
||||||
|
|
||||||
|
if ($selectedItemIds !== null) {
|
||||||
|
$query->whereIn('id', $selectedItemIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->orderBy('id')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, BackupItem> $policyItems
|
||||||
|
* @param array<string, string|null> $groupMapping
|
||||||
|
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
|
||||||
|
*/
|
||||||
|
private function checkOrphanedGroups(Tenant $tenant, Collection $policyItems, array $groupMapping): ?array
|
||||||
|
{
|
||||||
|
[$groupIds, $sourceNames] = $this->extractGroupIds($policyItems);
|
||||||
|
|
||||||
|
if ($groupIds === []) {
|
||||||
|
return [
|
||||||
|
'code' => 'assignment_groups',
|
||||||
|
'severity' => 'safe',
|
||||||
|
'title' => 'Assignments',
|
||||||
|
'message' => 'No group-based assignments detected.',
|
||||||
|
'meta' => [
|
||||||
|
'group_count' => 0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$graphOptions = $tenant->graphOptions();
|
||||||
|
$tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
|
||||||
|
$resolved = $this->groupResolver->resolveGroupIds($groupIds, $tenantIdentifier, $graphOptions);
|
||||||
|
|
||||||
|
$orphaned = [];
|
||||||
|
|
||||||
|
foreach ($groupIds as $groupId) {
|
||||||
|
$group = $resolved[$groupId] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($group) || ! ($group['orphaned'] ?? false)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$orphaned[] = [
|
||||||
|
'id' => $groupId,
|
||||||
|
'label' => $this->formatGroupLabel($sourceNames[$groupId] ?? null, $groupId),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($orphaned === []) {
|
||||||
|
return [
|
||||||
|
'code' => 'assignment_groups',
|
||||||
|
'severity' => 'safe',
|
||||||
|
'title' => 'Assignments',
|
||||||
|
'message' => sprintf('%d group assignment targets resolved.', count($groupIds)),
|
||||||
|
'meta' => [
|
||||||
|
'group_count' => count($groupIds),
|
||||||
|
'orphaned_count' => 0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$unmapped = [];
|
||||||
|
$mapped = [];
|
||||||
|
$skipped = [];
|
||||||
|
|
||||||
|
foreach ($orphaned as $group) {
|
||||||
|
$groupId = $group['id'];
|
||||||
|
$mapping = $groupMapping[$groupId] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($mapping) || $mapping === '') {
|
||||||
|
$unmapped[] = $group;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mapping === 'SKIP') {
|
||||||
|
$skipped[] = $group;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mapped[] = $group + [
|
||||||
|
'mapped_to' => $mapping,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$severity = $unmapped !== [] ? 'blocking' : 'warning';
|
||||||
|
|
||||||
|
$message = $unmapped !== []
|
||||||
|
? sprintf('%d group assignment targets are missing in the tenant and require mapping (or skip).', count($unmapped))
|
||||||
|
: sprintf('%d group assignment targets are missing in the tenant (mapped/skipped).', count($orphaned));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'code' => 'assignment_groups',
|
||||||
|
'severity' => $severity,
|
||||||
|
'title' => 'Assignments',
|
||||||
|
'message' => $message,
|
||||||
|
'meta' => [
|
||||||
|
'group_count' => count($groupIds),
|
||||||
|
'orphaned_count' => count($orphaned),
|
||||||
|
'unmapped' => $unmapped,
|
||||||
|
'mapped' => $mapped,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, BackupItem> $policyItems
|
||||||
|
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
|
||||||
|
*/
|
||||||
|
private function checkPreviewOnlyPolicies(Collection $policyItems): ?array
|
||||||
|
{
|
||||||
|
$byType = [];
|
||||||
|
|
||||||
|
foreach ($policyItems as $item) {
|
||||||
|
$restoreMode = $this->resolveRestoreMode($item->policy_type);
|
||||||
|
|
||||||
|
if ($restoreMode !== 'preview-only') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = $this->resolveTypeLabel($item->policy_type);
|
||||||
|
$byType[$label] ??= 0;
|
||||||
|
$byType[$label]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($byType === []) {
|
||||||
|
return [
|
||||||
|
'code' => 'preview_only',
|
||||||
|
'severity' => 'safe',
|
||||||
|
'title' => 'Preview-only types',
|
||||||
|
'message' => 'No preview-only policy types detected.',
|
||||||
|
'meta' => [
|
||||||
|
'count' => 0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'code' => 'preview_only',
|
||||||
|
'severity' => 'warning',
|
||||||
|
'title' => 'Preview-only types',
|
||||||
|
'message' => 'Some selected items are preview-only and will never execute.',
|
||||||
|
'meta' => [
|
||||||
|
'count' => array_sum($byType),
|
||||||
|
'types' => $byType,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, BackupItem> $policyItems
|
||||||
|
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
|
||||||
|
*/
|
||||||
|
private function checkMissingPolicies(Tenant $tenant, Collection $policyItems): ?array
|
||||||
|
{
|
||||||
|
$pairs = [];
|
||||||
|
|
||||||
|
foreach ($policyItems as $item) {
|
||||||
|
$identifier = $item->policy_identifier;
|
||||||
|
$type = $item->policy_type;
|
||||||
|
|
||||||
|
if (! is_string($identifier) || $identifier === '' || ! is_string($type) || $type === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pairs[] = [
|
||||||
|
'identifier' => $identifier,
|
||||||
|
'type' => $type,
|
||||||
|
'label' => $item->resolvedDisplayName(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pairs === []) {
|
||||||
|
return [
|
||||||
|
'code' => 'missing_policies',
|
||||||
|
'severity' => 'safe',
|
||||||
|
'title' => 'Target policies',
|
||||||
|
'message' => 'No policy identifiers available to verify.',
|
||||||
|
'meta' => [
|
||||||
|
'missing_count' => 0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$identifiers = array_values(array_unique(array_column($pairs, 'identifier')));
|
||||||
|
$types = array_values(array_unique(array_column($pairs, 'type')));
|
||||||
|
|
||||||
|
$existing = Policy::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->whereIn('external_id', $identifiers)
|
||||||
|
->whereIn('policy_type', $types)
|
||||||
|
->get(['id', 'external_id', 'policy_type'])
|
||||||
|
->mapWithKeys(fn (Policy $policy) => [$this->policyKey($policy->policy_type, $policy->external_id) => $policy->id])
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$missing = [];
|
||||||
|
|
||||||
|
foreach ($pairs as $pair) {
|
||||||
|
$key = $this->policyKey($pair['type'], $pair['identifier']);
|
||||||
|
|
||||||
|
if (array_key_exists($key, $existing)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$missing[] = [
|
||||||
|
'type' => $pair['type'],
|
||||||
|
'identifier' => $pair['identifier'],
|
||||||
|
'label' => $pair['label'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$missing = array_values(collect($missing)->unique(fn (array $row) => $this->policyKey($row['type'], $row['identifier']))->all());
|
||||||
|
|
||||||
|
if ($missing === []) {
|
||||||
|
return [
|
||||||
|
'code' => 'missing_policies',
|
||||||
|
'severity' => 'safe',
|
||||||
|
'title' => 'Target policies',
|
||||||
|
'message' => 'All policies exist in the tenant (restore will update).',
|
||||||
|
'meta' => [
|
||||||
|
'missing_count' => 0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'code' => 'missing_policies',
|
||||||
|
'severity' => 'warning',
|
||||||
|
'title' => 'Target policies',
|
||||||
|
'message' => sprintf('%d policies do not exist in the tenant and will be created.', count($missing)),
|
||||||
|
'meta' => [
|
||||||
|
'missing_count' => count($missing),
|
||||||
|
'missing' => $this->truncateList($missing, 10),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, BackupItem> $policyItems
|
||||||
|
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
|
||||||
|
*/
|
||||||
|
private function checkStalePolicies(Tenant $tenant, Collection $policyItems): ?array
|
||||||
|
{
|
||||||
|
$itemsByPolicyId = [];
|
||||||
|
|
||||||
|
foreach ($policyItems as $item) {
|
||||||
|
if (! $item->policy_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$capturedAt = $item->captured_at;
|
||||||
|
|
||||||
|
if (! $capturedAt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$itemsByPolicyId[$item->policy_id][] = [
|
||||||
|
'backup_item_id' => $item->id,
|
||||||
|
'captured_at' => $capturedAt,
|
||||||
|
'label' => $item->resolvedDisplayName(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($itemsByPolicyId === []) {
|
||||||
|
return [
|
||||||
|
'code' => 'stale_policies',
|
||||||
|
'severity' => 'safe',
|
||||||
|
'title' => 'Staleness',
|
||||||
|
'message' => 'No captured timestamps available to evaluate staleness.',
|
||||||
|
'meta' => [
|
||||||
|
'stale_count' => 0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$latestVersions = PolicyVersion::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->whereIn('policy_id', array_keys($itemsByPolicyId))
|
||||||
|
->selectRaw('policy_id, max(captured_at) as latest_captured_at')
|
||||||
|
->groupBy('policy_id')
|
||||||
|
->get()
|
||||||
|
->mapWithKeys(function (PolicyVersion $version) {
|
||||||
|
$latestCapturedAt = $version->getAttribute('latest_captured_at');
|
||||||
|
|
||||||
|
if (is_string($latestCapturedAt) && $latestCapturedAt !== '') {
|
||||||
|
$latestCapturedAt = CarbonImmutable::parse($latestCapturedAt);
|
||||||
|
} else {
|
||||||
|
$latestCapturedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
(int) $version->policy_id => $latestCapturedAt,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$stale = [];
|
||||||
|
|
||||||
|
foreach ($itemsByPolicyId as $policyId => $policyItems) {
|
||||||
|
$latestCapturedAt = $latestVersions[(int) $policyId] ?? null;
|
||||||
|
|
||||||
|
if (! $latestCapturedAt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($policyItems as $policyItem) {
|
||||||
|
if ($latestCapturedAt->greaterThan($policyItem['captured_at'])) {
|
||||||
|
$stale[] = [
|
||||||
|
'backup_item_id' => $policyItem['backup_item_id'],
|
||||||
|
'label' => $policyItem['label'],
|
||||||
|
'snapshot_captured_at' => $policyItem['captured_at']->toIso8601String(),
|
||||||
|
'latest_captured_at' => $latestCapturedAt->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stale === []) {
|
||||||
|
return [
|
||||||
|
'code' => 'stale_policies',
|
||||||
|
'severity' => 'safe',
|
||||||
|
'title' => 'Staleness',
|
||||||
|
'message' => 'No newer versions detected since the snapshot.',
|
||||||
|
'meta' => [
|
||||||
|
'stale_count' => 0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'code' => 'stale_policies',
|
||||||
|
'severity' => 'warning',
|
||||||
|
'title' => 'Staleness',
|
||||||
|
'message' => sprintf('%d policies have newer versions in the tenant than this snapshot.', count($stale)),
|
||||||
|
'meta' => [
|
||||||
|
'stale_count' => count($stale),
|
||||||
|
'stale' => $this->truncateList($stale, 10),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, BackupItem> $items
|
||||||
|
* @param Collection<int, BackupItem> $policyItems
|
||||||
|
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
|
||||||
|
*/
|
||||||
|
private function checkMissingScopeTagsInScope(Collection $items, Collection $policyItems, bool $isSelectedScope): ?array
|
||||||
|
{
|
||||||
|
if (! $isSelectedScope) {
|
||||||
|
return [
|
||||||
|
'code' => 'scope_tags_in_scope',
|
||||||
|
'severity' => 'safe',
|
||||||
|
'title' => 'Scope tags',
|
||||||
|
'message' => 'Scope includes all items; foundations are available if present in the backup set.',
|
||||||
|
'meta' => [
|
||||||
|
'missing_scope_tags' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectedScopeTagCount = $items->where('policy_type', 'roleScopeTag')->count();
|
||||||
|
|
||||||
|
$scopeTagIds = [];
|
||||||
|
|
||||||
|
foreach ($policyItems as $item) {
|
||||||
|
$ids = $item->scope_tag_ids;
|
||||||
|
|
||||||
|
if (! is_array($ids)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
if (! is_string($id) || $id === '' || $id === '0') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scopeTagIds[] = $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$scopeTagIds = array_values(array_unique($scopeTagIds));
|
||||||
|
|
||||||
|
if ($scopeTagIds === [] || $selectedScopeTagCount > 0) {
|
||||||
|
return [
|
||||||
|
'code' => 'scope_tags_in_scope',
|
||||||
|
'severity' => 'safe',
|
||||||
|
'title' => 'Scope tags',
|
||||||
|
'message' => 'Scope tags look OK for the selected items.',
|
||||||
|
'meta' => [
|
||||||
|
'missing_scope_tags' => false,
|
||||||
|
'referenced_scope_tags' => count($scopeTagIds),
|
||||||
|
'selected_scope_tag_items' => $selectedScopeTagCount,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'code' => 'scope_tags_in_scope',
|
||||||
|
'severity' => 'warning',
|
||||||
|
'title' => 'Scope tags',
|
||||||
|
'message' => 'Policies reference scope tags, but scope tags are not included in the selected restore scope.',
|
||||||
|
'meta' => [
|
||||||
|
'missing_scope_tags' => true,
|
||||||
|
'referenced_scope_tags' => count($scopeTagIds),
|
||||||
|
'selected_scope_tag_items' => 0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, BackupItem> $policyItems
|
||||||
|
* @return array{0: array<int, string>, 1: array<string, string>}
|
||||||
|
*/
|
||||||
|
private function extractGroupIds(Collection $policyItems): array
|
||||||
|
{
|
||||||
|
$groupIds = [];
|
||||||
|
$sourceNames = [];
|
||||||
|
|
||||||
|
foreach ($policyItems as $item) {
|
||||||
|
if (! is_array($item->assignments) || $item->assignments === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($item->assignments as $assignment) {
|
||||||
|
if (! is_array($assignment)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = $assignment['target'] ?? [];
|
||||||
|
$odataType = $target['@odata.type'] ?? '';
|
||||||
|
|
||||||
|
if (! in_array($odataType, [
|
||||||
|
'#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'#microsoft.graph.exclusionGroupAssignmentTarget',
|
||||||
|
], true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupId = $target['groupId'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($groupId) || $groupId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupIds[] = $groupId;
|
||||||
|
|
||||||
|
$displayName = $target['group_display_name'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($displayName) && $displayName !== '') {
|
||||||
|
$sourceNames[$groupId] = $displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupIds = array_values(array_unique($groupIds));
|
||||||
|
|
||||||
|
return [$groupIds, $sourceNames];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatGroupLabel(?string $name, string $id): string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
if (is_string($name) && $name !== '') {
|
||||||
|
$parts[] = $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts[] = Str::limit($id, 24, '...');
|
||||||
|
|
||||||
|
return implode(' • ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function policyKey(string $type, string $identifier): string
|
||||||
|
{
|
||||||
|
return $type.'|'.$identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function resolveTypeMeta(?string $type): array
|
||||||
|
{
|
||||||
|
if (! is_string($type) || $type === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$types = array_merge(
|
||||||
|
config('tenantpilot.supported_policy_types', []),
|
||||||
|
config('tenantpilot.foundation_types', [])
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($types as $typeConfig) {
|
||||||
|
if (($typeConfig['type'] ?? null) === $type) {
|
||||||
|
return is_array($typeConfig) ? $typeConfig : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveRestoreMode(?string $policyType): string
|
||||||
|
{
|
||||||
|
$meta = $this->resolveTypeMeta($policyType);
|
||||||
|
|
||||||
|
return (string) ($meta['restore'] ?? 'enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTypeLabel(?string $policyType): string
|
||||||
|
{
|
||||||
|
$meta = $this->resolveTypeMeta($policyType);
|
||||||
|
|
||||||
|
return (string) ($meta['label'] ?? $policyType ?? 'Unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $items
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function truncateList(array $items, int $limit): array
|
||||||
|
{
|
||||||
|
if (count($items) <= $limit) {
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_slice($items, 0, $limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -40,6 +40,10 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
|
|||||||
{
|
{
|
||||||
$this->assertActiveContext($tenant, $backupSet);
|
$this->assertActiveContext($tenant, $backupSet);
|
||||||
|
|
||||||
|
if ($selectedItemIds === []) {
|
||||||
|
$selectedItemIds = null;
|
||||||
|
}
|
||||||
|
|
||||||
$items = $this->loadItems($backupSet, $selectedItemIds);
|
$items = $this->loadItems($backupSet, $selectedItemIds);
|
||||||
|
|
||||||
[$foundationItems, $policyItems] = $this->splitItems($items);
|
[$foundationItems, $policyItems] = $this->splitItems($items);
|
||||||
@ -180,6 +184,45 @@ public function executeFromPolicyVersion(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function executeForRun(
|
||||||
|
RestoreRun $restoreRun,
|
||||||
|
Tenant $tenant,
|
||||||
|
BackupSet $backupSet,
|
||||||
|
?string $actorEmail = null,
|
||||||
|
?string $actorName = null,
|
||||||
|
): RestoreRun {
|
||||||
|
$this->assertActiveContext($tenant, $backupSet);
|
||||||
|
|
||||||
|
if ($restoreRun->tenant_id !== $tenant->id) {
|
||||||
|
throw new \InvalidArgumentException('Restore run does not belong to the provided tenant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($restoreRun->backup_set_id !== $backupSet->id) {
|
||||||
|
throw new \InvalidArgumentException('Restore run does not belong to the provided backup set.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($restoreRun->status, ['completed', 'partial', 'failed', 'cancelled'], true)) {
|
||||||
|
throw new \RuntimeException('Restore run is already finished.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectedItemIds = is_array($restoreRun->requested_items) ? $restoreRun->requested_items : null;
|
||||||
|
|
||||||
|
if ($selectedItemIds === []) {
|
||||||
|
$selectedItemIds = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->execute(
|
||||||
|
tenant: $tenant,
|
||||||
|
backupSet: $backupSet,
|
||||||
|
selectedItemIds: $selectedItemIds,
|
||||||
|
dryRun: (bool) $restoreRun->is_dry_run,
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
groupMapping: $restoreRun->group_mapping ?? [],
|
||||||
|
existingRun: $restoreRun,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function execute(
|
public function execute(
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
BackupSet $backupSet,
|
BackupSet $backupSet,
|
||||||
@ -188,26 +231,65 @@ public function execute(
|
|||||||
?string $actorEmail = null,
|
?string $actorEmail = null,
|
||||||
?string $actorName = null,
|
?string $actorName = null,
|
||||||
array $groupMapping = [],
|
array $groupMapping = [],
|
||||||
|
?RestoreRun $existingRun = null,
|
||||||
): RestoreRun {
|
): RestoreRun {
|
||||||
$this->assertActiveContext($tenant, $backupSet);
|
$this->assertActiveContext($tenant, $backupSet);
|
||||||
|
|
||||||
|
if ($selectedItemIds === []) {
|
||||||
|
$selectedItemIds = null;
|
||||||
|
}
|
||||||
|
|
||||||
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
||||||
$items = $this->loadItems($backupSet, $selectedItemIds);
|
$items = $this->loadItems($backupSet, $selectedItemIds);
|
||||||
[$foundationItems, $policyItems] = $this->splitItems($items);
|
[$foundationItems, $policyItems] = $this->splitItems($items);
|
||||||
$preview = $this->preview($tenant, $backupSet, $selectedItemIds);
|
$preview = $this->preview($tenant, $backupSet, $selectedItemIds);
|
||||||
|
|
||||||
$restoreRun = RestoreRun::create([
|
$wizardMetadata = [
|
||||||
'tenant_id' => $tenant->id,
|
'scope_mode' => $selectedItemIds === null ? 'all' : 'selected',
|
||||||
'backup_set_id' => $backupSet->id,
|
'environment' => app()->environment('production') ? 'prod' : 'test',
|
||||||
'requested_by' => $actorEmail,
|
'highlander_label' => (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()),
|
||||||
'is_dry_run' => $dryRun,
|
];
|
||||||
'status' => 'running',
|
|
||||||
'requested_items' => $selectedItemIds,
|
if ($existingRun !== null) {
|
||||||
'preview' => $preview,
|
if ($existingRun->tenant_id !== $tenant->id) {
|
||||||
'started_at' => CarbonImmutable::now(),
|
throw new \InvalidArgumentException('Restore run does not belong to the provided tenant.');
|
||||||
'metadata' => [],
|
}
|
||||||
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
|
|
||||||
]);
|
if ($existingRun->backup_set_id !== $backupSet->id) {
|
||||||
|
throw new \InvalidArgumentException('Restore run does not belong to the provided backup set.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$metadata = array_merge($wizardMetadata, $existingRun->metadata ?? []);
|
||||||
|
|
||||||
|
$existingRun->update([
|
||||||
|
'requested_by' => $existingRun->requested_by ?? $actorEmail,
|
||||||
|
'is_dry_run' => $dryRun,
|
||||||
|
'status' => 'running',
|
||||||
|
'requested_items' => $selectedItemIds,
|
||||||
|
'preview' => $preview,
|
||||||
|
'results' => null,
|
||||||
|
'failure_reason' => null,
|
||||||
|
'started_at' => $existingRun->started_at ?? CarbonImmutable::now(),
|
||||||
|
'completed_at' => null,
|
||||||
|
'metadata' => $metadata,
|
||||||
|
'group_mapping' => $groupMapping !== [] ? $groupMapping : ($existingRun->group_mapping ?? null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$restoreRun = $existingRun->refresh();
|
||||||
|
} else {
|
||||||
|
$restoreRun = RestoreRun::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'requested_by' => $actorEmail,
|
||||||
|
'is_dry_run' => $dryRun,
|
||||||
|
'status' => 'running',
|
||||||
|
'requested_items' => $selectedItemIds,
|
||||||
|
'preview' => $preview,
|
||||||
|
'started_at' => CarbonImmutable::now(),
|
||||||
|
'metadata' => $wizardMetadata,
|
||||||
|
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
if ($groupMapping !== []) {
|
if ($groupMapping !== []) {
|
||||||
$this->auditLogger->log(
|
$this->auditLogger->log(
|
||||||
@ -740,12 +822,12 @@ public function execute(
|
|||||||
'status' => $status,
|
'status' => $status,
|
||||||
'results' => $results,
|
'results' => $results,
|
||||||
'completed_at' => CarbonImmutable::now(),
|
'completed_at' => CarbonImmutable::now(),
|
||||||
'metadata' => [
|
'metadata' => array_merge($restoreRun->metadata ?? [], [
|
||||||
'failed' => $hardFailures,
|
'failed' => $hardFailures,
|
||||||
'non_applied' => $nonApplied,
|
'non_applied' => $nonApplied,
|
||||||
'total' => $totalCount,
|
'total' => $totalCount,
|
||||||
'foundations_skipped' => $foundationSkipped,
|
'foundations_skipped' => $foundationSkipped,
|
||||||
],
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->auditLogger->log(
|
$this->auditLogger->log(
|
||||||
|
|||||||
73
app/Support/RestoreRunStatus.php
Normal file
73
app/Support/RestoreRunStatus.php
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
enum RestoreRunStatus: string
|
||||||
|
{
|
||||||
|
case Draft = 'draft';
|
||||||
|
case Scoped = 'scoped';
|
||||||
|
case Checked = 'checked';
|
||||||
|
case Previewed = 'previewed';
|
||||||
|
case Pending = 'pending';
|
||||||
|
case Queued = 'queued';
|
||||||
|
case Running = 'running';
|
||||||
|
case Completed = 'completed';
|
||||||
|
case Partial = 'partial';
|
||||||
|
case Failed = 'failed';
|
||||||
|
case Cancelled = 'cancelled';
|
||||||
|
|
||||||
|
// Legacy / compatibility statuses (existing housekeeping semantics)
|
||||||
|
case Aborted = 'aborted';
|
||||||
|
case CompletedWithErrors = 'completed_with_errors';
|
||||||
|
|
||||||
|
public static function fromString(?string $value): ?self
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = strtolower(trim($value));
|
||||||
|
$normalized = str_replace([' ', '-'], '_', $normalized);
|
||||||
|
|
||||||
|
return self::tryFrom($normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canTransitionTo(self $next): bool
|
||||||
|
{
|
||||||
|
if ($this === $next) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($this) {
|
||||||
|
self::Draft => in_array($next, [self::Scoped, self::Cancelled], true),
|
||||||
|
self::Scoped => in_array($next, [self::Checked, self::Cancelled], true),
|
||||||
|
self::Checked => in_array($next, [self::Previewed, self::Cancelled], true),
|
||||||
|
self::Previewed => in_array($next, [self::Queued, self::Cancelled], true),
|
||||||
|
self::Pending => in_array($next, [self::Queued, self::Running, self::Cancelled], true),
|
||||||
|
self::Queued => in_array($next, [self::Running, self::Cancelled], true),
|
||||||
|
self::Running => in_array($next, [self::Completed, self::Partial, self::Failed, self::Cancelled], true),
|
||||||
|
self::Completed,
|
||||||
|
self::Partial,
|
||||||
|
self::Failed,
|
||||||
|
self::Cancelled,
|
||||||
|
self::Aborted,
|
||||||
|
self::CompletedWithErrors => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDeletable(): bool
|
||||||
|
{
|
||||||
|
return in_array($this, [
|
||||||
|
self::Draft,
|
||||||
|
self::Scoped,
|
||||||
|
self::Checked,
|
||||||
|
self::Previewed,
|
||||||
|
self::Completed,
|
||||||
|
self::Partial,
|
||||||
|
self::Failed,
|
||||||
|
self::Cancelled,
|
||||||
|
self::Aborted,
|
||||||
|
self::CompletedWithErrors,
|
||||||
|
], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,121 @@
|
|||||||
|
@php
|
||||||
|
$fieldWrapperView = $getFieldWrapperView();
|
||||||
|
|
||||||
|
$results = $getState() ?? [];
|
||||||
|
$results = is_array($results) ? $results : [];
|
||||||
|
|
||||||
|
$summary = $summary ?? [];
|
||||||
|
$summary = is_array($summary) ? $summary : [];
|
||||||
|
|
||||||
|
$blocking = (int) ($summary['blocking'] ?? 0);
|
||||||
|
$warning = (int) ($summary['warning'] ?? 0);
|
||||||
|
$safe = (int) ($summary['safe'] ?? 0);
|
||||||
|
|
||||||
|
$ranAt = $ranAt ?? null;
|
||||||
|
$ranAtLabel = null;
|
||||||
|
|
||||||
|
if (is_string($ranAt) && $ranAt !== '') {
|
||||||
|
try {
|
||||||
|
$ranAtLabel = \Carbon\CarbonImmutable::parse($ranAt)->format('Y-m-d H:i');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$ranAtLabel = $ranAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$severityColor = static function (?string $severity): string {
|
||||||
|
return match ($severity) {
|
||||||
|
'blocking' => 'danger',
|
||||||
|
'warning' => 'warning',
|
||||||
|
default => 'success',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
$limitedList = static function (array $items, int $limit = 5): array {
|
||||||
|
if (count($items) <= $limit) {
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_slice($items, 0, $limit);
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<x-filament::section
|
||||||
|
heading="Safety checks"
|
||||||
|
:description="$ranAtLabel ? ('Last run: ' . $ranAtLabel) : 'Run checks to evaluate risk before previewing.'"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-filament::badge :color="$blocking > 0 ? 'danger' : 'gray'">
|
||||||
|
{{ $blocking }} blocking
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge :color="$warning > 0 ? 'warning' : 'gray'">
|
||||||
|
{{ $warning }} warnings
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge :color="$safe > 0 ? 'success' : 'gray'">
|
||||||
|
{{ $safe }} safe
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
@if ($results === [])
|
||||||
|
<x-filament::section>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
No checks have been run yet.
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@else
|
||||||
|
<div class="space-y-3">
|
||||||
|
@foreach ($results as $result)
|
||||||
|
@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) : [];
|
||||||
|
@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 }}
|
||||||
|
</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="$severityColor($severity)" size="sm">
|
||||||
|
{{ ucfirst((string) $severity) }}
|
||||||
|
</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>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-dynamic-component>
|
||||||
@ -0,0 +1,180 @@
|
|||||||
|
@php
|
||||||
|
$fieldWrapperView = $getFieldWrapperView();
|
||||||
|
|
||||||
|
$diffs = $getState() ?? [];
|
||||||
|
$diffs = is_array($diffs) ? $diffs : [];
|
||||||
|
|
||||||
|
$summary = $summary ?? [];
|
||||||
|
$summary = is_array($summary) ? $summary : [];
|
||||||
|
|
||||||
|
$ranAt = $ranAt ?? null;
|
||||||
|
$ranAtLabel = null;
|
||||||
|
|
||||||
|
if (is_string($ranAt) && $ranAt !== '') {
|
||||||
|
try {
|
||||||
|
$ranAtLabel = \Carbon\CarbonImmutable::parse($ranAt)->format('Y-m-d H:i');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$ranAtLabel = $ranAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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);
|
||||||
|
|
||||||
|
$limitedKeys = static function (array $items, int $limit = 8): array {
|
||||||
|
$keys = array_keys($items);
|
||||||
|
|
||||||
|
if (count($keys) <= $limit) {
|
||||||
|
return $keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_slice($keys, 0, $limit);
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<x-filament::section
|
||||||
|
heading="Preview"
|
||||||
|
:description="$ranAtLabel ? ('Generated: ' . $ranAtLabel) : 'Generate a preview to see what would change.'"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
@if ($diffs === [])
|
||||||
|
<x-filament::section>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
No preview generated 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
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@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.
|
||||||
|
</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>
|
||||||
|
@endif
|
||||||
|
</x-filament::section>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-dynamic-component>
|
||||||
75
specs/011-restore-run-wizard/plan.md
Normal file
75
specs/011-restore-run-wizard/plan.md
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
# Implementation Plan: Restore Run Wizard (011)
|
||||||
|
|
||||||
|
**Branch**: `feat/011-restore-run-wizard` | **Date**: 2025-12-30
|
||||||
|
**Input**: Feature specification in `specs/011-restore-run-wizard/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Refactor Restore Run creation into a **Filament Wizard** that enforces **Safety First**:
|
||||||
|
source → scope → safety checks → preview → confirm + execute.
|
||||||
|
|
||||||
|
Leverage existing restore primitives (`RestoreService::preview()` / `RestoreService::execute()`) and incrementally introduce:
|
||||||
|
- structured **risk checks**
|
||||||
|
- **diff preview** artifacts/summaries
|
||||||
|
- stronger **execution gating** + audit fields
|
||||||
|
|
||||||
|
## Technical Context (current code)
|
||||||
|
- Filament Resource: `app/Filament/Resources/RestoreRunResource.php` (single form today)
|
||||||
|
- Restore engine: `app/Services/Intune/RestoreService.php` (preview + execute)
|
||||||
|
- Diff tools: `app/Services/Intune/PolicyNormalizer.php` + `app/Services/Intune/VersionDiff.php`
|
||||||
|
- Data model: `restore_runs` already stores `preview`, `results`, `metadata`, `requested_items`
|
||||||
|
|
||||||
|
## Phase 1 — Data + State Model (Wizard-ready)
|
||||||
|
- Define restore run lifecycle statuses (string enum values).
|
||||||
|
- Decide what is stored as dedicated columns vs `restore_runs.metadata` JSON.
|
||||||
|
- Add minimal persistence for wizard state:
|
||||||
|
- `scope_mode`, `check_summary`, `check_results`, `preview_summary`, `confirmed_at/by`, `environment`, `highlander_label`.
|
||||||
|
|
||||||
|
**Checkpoint**: RestoreRun can represent wizard progression and persist computations.
|
||||||
|
|
||||||
|
## Phase 2 — Filament Wizard UI (Create Restore Run)
|
||||||
|
- Replace the single Create form with a 5-step wizard UI.
|
||||||
|
- Implement step-level validation and state resets (changing backup set resets downstream).
|
||||||
|
- Keep dry-run default ON, and make execution UI unavailable until the wizard rules are satisfied.
|
||||||
|
|
||||||
|
**Checkpoint**: Wizard is usable end-to-end in dry-run.
|
||||||
|
|
||||||
|
## Phase 3 — Restore Scope Builder (Selection UX)
|
||||||
|
- Build grouped selection UI for BackupItems (type/platform), with search and “select all”.
|
||||||
|
- Clearly mark:
|
||||||
|
- foundations vs policies
|
||||||
|
- preview-only types
|
||||||
|
- items missing policy_version linkage / snapshot completeness hints (optional)
|
||||||
|
|
||||||
|
**Checkpoint**: Scoping is explicit, scalable, and safe.
|
||||||
|
|
||||||
|
## Phase 4 — Safety & Conflict Checks (RestoreRiskChecker)
|
||||||
|
- Implement server-side checks for the chosen scope.
|
||||||
|
- Persist results on the RestoreRun and display with severity badges.
|
||||||
|
- Block execution if blockers exist.
|
||||||
|
|
||||||
|
**Checkpoint**: Defensive layer in place; blockers stop execution.
|
||||||
|
|
||||||
|
## Phase 5 — Preview (RestoreDiffGenerator)
|
||||||
|
- Generate a diff summary (minimum) comparing backup snapshot vs current target state.
|
||||||
|
- Persist preview summary (and optionally per-item diffs with limits).
|
||||||
|
- Require preview completion before allowing execute.
|
||||||
|
|
||||||
|
**Checkpoint**: Preview step is a hard gate for execute and is auditable.
|
||||||
|
|
||||||
|
## Phase 6 — Confirm & Execute
|
||||||
|
- Add explicit confirmations:
|
||||||
|
- “I reviewed the impact”
|
||||||
|
- tenant hard-confirm (Highlander)
|
||||||
|
- environment badge (frozen at run creation)
|
||||||
|
- Execute restore via queue job (preferred) or synchronous execution (only if queue is out of scope for MVP).
|
||||||
|
- Update run statuses and persist outcomes.
|
||||||
|
|
||||||
|
**Checkpoint**: Execution is safe, gated, and traceable.
|
||||||
|
|
||||||
|
## Phase 7 — Tests + QA
|
||||||
|
- Pest feature tests for:
|
||||||
|
- wizard gating rules (execute disabled until conditions satisfied)
|
||||||
|
- safety checks persistence and blocking behavior
|
||||||
|
- preview summary generation
|
||||||
|
- Run targeted tests and Pint.
|
||||||
|
|
||||||
224
specs/011-restore-run-wizard/spec.md
Normal file
224
specs/011-restore-run-wizard/spec.md
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
# Feature Specification: Restore Run Wizard (011)
|
||||||
|
|
||||||
|
**Feature Branch**: `feat/011-restore-run-wizard`
|
||||||
|
**Created**: 2025-12-30
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: Restore Run Wizard requirements (Safety First / Defensive Restore)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Implement **Restore Runs** as a **multi-step Wizard** (instead of a single “Create Restore Run” form) to enforce **Safety First / Defensive Restore**.
|
||||||
|
|
||||||
|
Restore is a high-risk workflow. The wizard must guide admins through explicit checkpoints:
|
||||||
|
source selection → scoping → safety checks → preview → confirmation + execution.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
The current Restore Run creation is a single form that can lead to:
|
||||||
|
- picking the wrong backup source
|
||||||
|
- restoring too broad a scope unintentionally
|
||||||
|
- executing without a structured “risk + preview + explicit confirmation” flow
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
- Make restore a **deliberate, stepwise** process with strong defaults.
|
||||||
|
- Make **dry-run** the default, and keep “Execute” disabled until all safety gates are satisfied.
|
||||||
|
- Add **server-side safety/conflict checks** and persist results for auditability.
|
||||||
|
- Provide a **preview** (diff summary at minimum) before allowing execution.
|
||||||
|
|
||||||
|
## Non-Goals (v1)
|
||||||
|
- Approval workflows / multi-person approvals (but design must not block future addition).
|
||||||
|
- Perfect diff UX parity with Intune (basic normalized diff output is enough).
|
||||||
|
- A generic wizard framework (restore-specific implementation is fine).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UX Principles
|
||||||
|
- **Dry-run default = ON**
|
||||||
|
- Wizard progression should slow the user down and force explicit decisions.
|
||||||
|
- “Execute” stays disabled until:
|
||||||
|
- Preview has been completed
|
||||||
|
- No blocking checks exist
|
||||||
|
- “I reviewed the impact” checkbox is checked
|
||||||
|
- Tenant hard-confirm matches (Highlander principle)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wizard Steps
|
||||||
|
|
||||||
|
### Step 1 — Select Backup Set (Source of Truth)
|
||||||
|
**Question:** “What are we restoring from?”
|
||||||
|
|
||||||
|
**Inputs**
|
||||||
|
- Backup Set (required)
|
||||||
|
|
||||||
|
**Read-only**
|
||||||
|
- Snapshot timestamp
|
||||||
|
- Tenant name
|
||||||
|
- Count of policies/items
|
||||||
|
- Types (Config / Security / Scripts …)
|
||||||
|
|
||||||
|
**Validation**
|
||||||
|
- `backup_set_id` is required
|
||||||
|
- Changing the backup set resets downstream state (scope, checks, preview, confirmation)
|
||||||
|
|
||||||
|
### Step 2 — Define Restore Scope (Selectivity)
|
||||||
|
**Question:** “What exactly should be restored?”
|
||||||
|
|
||||||
|
**Inputs**
|
||||||
|
- Scope mode: `all` (default) or `selected`
|
||||||
|
- If `selected`: item multiselect with search + select all
|
||||||
|
|
||||||
|
**UI**
|
||||||
|
- Prefer grouped by **type** and **platform**
|
||||||
|
- Mark “preview-only” types clearly
|
||||||
|
- Foundations should be discoverable (scope tags, assignment filters, notification templates)
|
||||||
|
|
||||||
|
**Notes**
|
||||||
|
- “Empty = all” only when scope mode is `all` (not when `selected`)
|
||||||
|
|
||||||
|
### Step 3 — Safety & Conflict Checks (Defensive Layer)
|
||||||
|
**Question:** “Is this dangerous?”
|
||||||
|
|
||||||
|
**Checks (server-side, persisted)**
|
||||||
|
- Target policy missing in target tenant?
|
||||||
|
- Target policy newer than backup? (staleness / overwrite risk)
|
||||||
|
- Assignments conflicts (e.g., mapping required / orphaned groups)
|
||||||
|
- Scope tag conflicts (mapping required / missing)
|
||||||
|
- Preview-only policies included in scope (should be warned and auto-dry-run)
|
||||||
|
|
||||||
|
**Severity**
|
||||||
|
- ❌ blocking
|
||||||
|
- ⚠️ warning
|
||||||
|
- ✅ safe
|
||||||
|
|
||||||
|
**Rules**
|
||||||
|
- Blocking checks prevent execution.
|
||||||
|
- Wizard may allow proceeding to preview, but must never allow execute while blockers exist.
|
||||||
|
|
||||||
|
### Step 4 — Preview (Dry-Run Simulation)
|
||||||
|
**Question:** “What would happen?”
|
||||||
|
|
||||||
|
**Outputs**
|
||||||
|
- Diff summary (at minimum):
|
||||||
|
- X policies changed
|
||||||
|
- Y assignments changed
|
||||||
|
- Z scope tags changed
|
||||||
|
- Per-item normalized diff (nice-to-have for v1, but plan for it)
|
||||||
|
|
||||||
|
**Defaults**
|
||||||
|
- “Preview only (Dry-run)” is ON by default
|
||||||
|
|
||||||
|
### Step 5 — Confirm & Execute (Point of No Return)
|
||||||
|
**Question:** “Do you really want to do this?”
|
||||||
|
|
||||||
|
**Confirmations**
|
||||||
|
- Checkbox: “I reviewed the impact”
|
||||||
|
- Tenant hard-confirm input (must match tenant display identifier)
|
||||||
|
- Environment badge (Prod/Test) highly visible (frozen at run start for audit)
|
||||||
|
|
||||||
|
**Rules**
|
||||||
|
- Execute disabled if:
|
||||||
|
- `dry_run = true`
|
||||||
|
- blockers exist
|
||||||
|
- tenant confirm mismatch
|
||||||
|
- acknowledgement unchecked
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Domain Model (v1-aligned)
|
||||||
|
We already have a `restore_runs` aggregate (`restore_runs` table) with:
|
||||||
|
- `backup_set_id`, `requested_items`, `preview`, `results`, `status`, `metadata`, timestamps, and `group_mapping`.
|
||||||
|
|
||||||
|
**v1 approach**
|
||||||
|
- Keep the existing primary key type (bigint) to avoid a disruptive migration.
|
||||||
|
- Extend the lifecycle/status semantics and persist wizard computations (checks + diff summaries) in structured fields:
|
||||||
|
- Prefer adding dedicated JSON columns only if needed; otherwise use `metadata` for wizard state.
|
||||||
|
|
||||||
|
### RestoreRun Lifecycle (proposed statuses)
|
||||||
|
`draft → scoped → checked → previewed → queued → running → completed|partial|failed|cancelled`
|
||||||
|
|
||||||
|
### Persisted Wizard State (minimum)
|
||||||
|
- `backup_set_id` (existing)
|
||||||
|
- `requested_items` (selected IDs, existing)
|
||||||
|
- `metadata.scope_mode` (`all|selected`)
|
||||||
|
- `metadata.environment` (`prod|test`)
|
||||||
|
- `metadata.highlander_label` (tenant identifier string, frozen)
|
||||||
|
- `metadata.check_summary` + `metadata.check_results` (Step 3)
|
||||||
|
- `metadata.preview_summary` + `metadata.preview_diffs` (Step 4; diffs may be truncated/limited)
|
||||||
|
- `metadata.confirmed_at`, `metadata.confirmed_by` (Step 5)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Services / Responsibilities
|
||||||
|
- **RestoreScopeBuilder**: build selectable restore items (grouped, searchable), include foundations & mark preview-only.
|
||||||
|
- **RestoreRiskChecker**: run safety checks, return structured results + summary.
|
||||||
|
- **RestoreDiffGenerator**: generate diff summary (and optionally per-item diffs) for preview.
|
||||||
|
- **RestoreExecutor**: execute restore (idempotent, tenant/run locking), write detailed outcomes.
|
||||||
|
- **RestoreRunPolicy**: enforce invariants (no execution without preview + confirmations).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 — Wizard-driven Restore Run (Priority: P1)
|
||||||
|
As an admin, I can create a restore run via a 5-step wizard and I cannot accidentally execute without preview + explicit confirmations.
|
||||||
|
|
||||||
|
**Why this priority**: This is the safety foundation; without it, restore remains risky UX.
|
||||||
|
|
||||||
|
**Independent Test**: In Filament, create a restore run with dry-run, see checks + preview, and confirm execute stays disabled until gates satisfied.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**
|
||||||
|
1. **Given** I select a backup set, **When** I move to the next step, **Then** scope/check/preview state is reset when I change the backup set again.
|
||||||
|
2. **Given** I keep dry-run enabled, **When** I reach Step 5, **Then** Execute is disabled.
|
||||||
|
3. **Given** I disable dry-run, **When** I have not completed preview, **Then** Execute is disabled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 — Safety Checks block execution (Priority: P1)
|
||||||
|
As an admin, I see blocking vs warning checks, and execution is blocked when blockers exist.
|
||||||
|
|
||||||
|
**Why this priority**: Defensive restore requires an explicit risk layer.
|
||||||
|
|
||||||
|
**Independent Test**: Create a scope that triggers a blocking check and verify execution cannot proceed.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**
|
||||||
|
1. **Given** a blocking check exists, **When** I reach Step 5, **Then** Execute remains disabled and blockers are visible.
|
||||||
|
2. **Given** only warnings exist, **When** I acknowledge impact and hard-confirm tenant, **Then** I can execute (dry-run off).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 — Preview diff summary (Priority: P2)
|
||||||
|
As an admin, I can preview what would change before executing restore.
|
||||||
|
|
||||||
|
**Why this priority**: A restore without preview is operationally unsafe.
|
||||||
|
|
||||||
|
**Independent Test**: Run Step 4 preview and verify diff summary is computed and persisted on the RestoreRun.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**
|
||||||
|
1. **Given** I scoped items, **When** I run preview, **Then** I see a summary (changed policies count) and it persists on the restore run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
- Very large backup sets (hundreds/thousands of items): selection/search must remain responsive.
|
||||||
|
- Switching backup set mid-flow resets downstream state safely.
|
||||||
|
- Policies not present in target tenant: shown as warning/blocker depending on restore mode.
|
||||||
|
- RBAC-limited tenant setup: checks must clearly show “inventory/restore may be partial”.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Functional Requirements
|
||||||
|
- **FR-011.1**: System MUST implement Restore Run creation as a 5-step wizard in Filament.
|
||||||
|
- **FR-011.2**: System MUST default `dry_run = true` and prevent execution while dry-run is enabled.
|
||||||
|
- **FR-011.3**: System MUST run server-side safety checks and persist results (summary + details) for audit.
|
||||||
|
- **FR-011.4**: System MUST generate at least a diff summary on preview and persist it.
|
||||||
|
- **FR-011.5**: System MUST require explicit acknowledgement + tenant hard-confirm before allowing execution.
|
||||||
|
- **FR-011.6**: System MUST freeze environment badge and tenant label for audit on run creation.
|
||||||
|
- **FR-011.7**: System MUST keep execution disabled if any blocking checks exist.
|
||||||
|
- **FR-011.8**: System MUST record execution outcomes and leave an auditable trail (existing audit log patterns).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
- **SC-011.1**: Admins can only execute after preview + confirmations; no accidental execution path exists.
|
||||||
|
- **SC-011.2**: Blocking checks reliably prevent execution.
|
||||||
|
- **SC-011.3**: Preview produces a persisted summary for every run.
|
||||||
|
|
||||||
45
specs/011-restore-run-wizard/tasks.md
Normal file
45
specs/011-restore-run-wizard/tasks.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# Tasks: Restore Run Wizard (011)
|
||||||
|
|
||||||
|
**Branch**: `feat/011-restore-run-wizard` | **Date**: 2025-12-30
|
||||||
|
**Input**: `specs/011-restore-run-wizard/spec.md`, `specs/011-restore-run-wizard/plan.md`
|
||||||
|
|
||||||
|
## Phase 0 — Specs (this PR)
|
||||||
|
- [x] T001 Create `spec.md`, `plan.md`, `tasks.md` for Feature 011.
|
||||||
|
|
||||||
|
## Phase 1 — Data Model + Status Semantics
|
||||||
|
- [x] T002 Define RestoreRun lifecycle statuses and transitions (draft→scoped→checked→previewed→queued→running→completed|partial|failed).
|
||||||
|
- [x] T003 Add minimal persistence for wizard state (prefer JSON in `restore_runs.metadata` unless columns are required).
|
||||||
|
- [x] T004 Freeze `environment` + `highlander_label` at run creation for audit.
|
||||||
|
|
||||||
|
## Phase 2 — Filament Wizard (Create Restore Run)
|
||||||
|
- [x] T005 Replace current single-form create with a 5-step wizard (Step 1–5 as in spec).
|
||||||
|
- [x] T006 Ensure changing `backup_set_id` resets downstream wizard state.
|
||||||
|
- [x] T007 Enforce “dry-run default ON” and keep execute disabled until all gates satisfied.
|
||||||
|
|
||||||
|
## Phase 3 — Restore Scope UX
|
||||||
|
- [x] T008 Implement scoped selection UI grouped by policy type + platform with search and bulk toggle.
|
||||||
|
- [x] T009 Mark preview-only types clearly and ensure they never execute.
|
||||||
|
- [x] T010 Ensure foundations are discoverable (assignment filters, scope tags, notification templates).
|
||||||
|
|
||||||
|
## Phase 4 — Safety & Conflict Checks
|
||||||
|
- [x] T011 Implement `RestoreRiskChecker` (server-side) and persist `check_summary` + `check_results`.
|
||||||
|
- [x] T012 Render check results with severity (blocking/warning/safe) and block execute when blockers exist.
|
||||||
|
|
||||||
|
## Phase 5 — Preview (Diff)
|
||||||
|
- [x] T013 Implement `RestoreDiffGenerator` using `PolicyNormalizer` + `VersionDiff`.
|
||||||
|
- [x] T014 Persist preview summary (and per-item diffs with safe limits) and require preview completion before execute.
|
||||||
|
|
||||||
|
## Phase 6 — Confirm & Execute
|
||||||
|
- [x] T015 Implement Step 5 confirmations (ack checkbox + tenant hard-confirm).
|
||||||
|
- [x] T016 Execute restore via a queued Job (preferred) and update statuses + timestamps.
|
||||||
|
- [x] T017 Persist execution outcomes and ensure audit logging entries exist for execution start/finish.
|
||||||
|
|
||||||
|
## Phase 7 — Tests + Formatting
|
||||||
|
- [x] T018 Add Pest tests for wizard gating rules and status transitions.
|
||||||
|
- [x] T019 Add Pest tests for safety checks persistence and blocking behavior.
|
||||||
|
- [x] T020 Add Pest tests for preview summary generation.
|
||||||
|
- [x] T021 Run `./vendor/bin/pint --dirty`.
|
||||||
|
- [x] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist).
|
||||||
|
|
||||||
|
## Phase 8 — Policy Version Entry Point (later)
|
||||||
|
- [x] T023 Add a “Restore via Wizard” action on `PolicyVersion` that creates a 1-item Backup Set (source = policy_version) and opens the Restore Run wizard prefilled/scoped to that item.
|
||||||
68
tests/Feature/ExecuteRestoreRunJobTest.php
Normal file
68
tests/Feature/ExecuteRestoreRunJobTest.php
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\ExecuteRestoreRunJob;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\RestoreRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use App\Services\Intune\RestoreService;
|
||||||
|
use App\Support\RestoreRunStatus;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('execute restore run job moves queued to running and calls the executor', function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-1',
|
||||||
|
'name' => 'Tenant One',
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Backup',
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$restoreRun = RestoreRun::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'requested_by' => 'actor@example.com',
|
||||||
|
'is_dry_run' => false,
|
||||||
|
'status' => RestoreRunStatus::Queued->value,
|
||||||
|
'requested_items' => null,
|
||||||
|
'preview' => [],
|
||||||
|
'results' => null,
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$restoreService = $this->mock(RestoreService::class, function (MockInterface $mock) use ($tenant, $backupSet) {
|
||||||
|
$mock->shouldReceive('executeForRun')
|
||||||
|
->once()
|
||||||
|
->withArgs(function (RestoreRun $run, Tenant $runTenant, BackupSet $runBackupSet, ?string $email, ?string $name) use ($tenant, $backupSet): bool {
|
||||||
|
return $run->status === RestoreRunStatus::Running->value
|
||||||
|
&& $runTenant->is($tenant)
|
||||||
|
&& $runBackupSet->is($backupSet)
|
||||||
|
&& $email === 'actor@example.com'
|
||||||
|
&& $name === 'Actor';
|
||||||
|
})
|
||||||
|
->andReturnUsing(function (RestoreRun $run): RestoreRun {
|
||||||
|
$run->update([
|
||||||
|
'status' => RestoreRunStatus::Completed->value,
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $run->refresh();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor');
|
||||||
|
$job->handle($restoreService, app(AuditLogger::class));
|
||||||
|
|
||||||
|
$restoreRun->refresh();
|
||||||
|
|
||||||
|
expect($restoreRun->started_at)->not->toBeNull();
|
||||||
|
expect($restoreRun->status)->toBe(RestoreRunStatus::Completed->value);
|
||||||
|
});
|
||||||
164
tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php
Normal file
164
tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\PolicyVersionResource\Pages\ListPolicyVersions;
|
||||||
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
|
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Graph\GroupResolver;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('policy version can open restore wizard via row action', function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-policy-version-wizard',
|
||||||
|
'name' => 'Tenant',
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'policy-1',
|
||||||
|
'policy_type' => 'settingsCatalogPolicy',
|
||||||
|
'display_name' => 'Settings Catalog',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version = PolicyVersion::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 3,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => now(),
|
||||||
|
'snapshot' => ['id' => $policy->external_id, 'displayName' => $policy->display_name],
|
||||||
|
'assignments' => [['intent' => 'apply']],
|
||||||
|
'scope_tags' => [
|
||||||
|
'ids' => ['st-1'],
|
||||||
|
'names' => ['Tag 1'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create(['email' => 'tester@example.com']);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(ListPolicyVersions::class)
|
||||||
|
->callTableAction('restore_via_wizard', $version)
|
||||||
|
->assertRedirectContains(RestoreRunResource::getUrl('create', [], false));
|
||||||
|
|
||||||
|
$backupSet = BackupSet::query()->where('metadata->source', 'policy_version')->first();
|
||||||
|
expect($backupSet)->not->toBeNull();
|
||||||
|
expect($backupSet->tenant_id)->toBe($tenant->id);
|
||||||
|
expect($backupSet->metadata['policy_version_id'] ?? null)->toBe($version->id);
|
||||||
|
|
||||||
|
$backupItem = BackupItem::query()->where('backup_set_id', $backupSet->id)->first();
|
||||||
|
expect($backupItem)->not->toBeNull();
|
||||||
|
expect($backupItem->policy_version_id)->toBe($version->id);
|
||||||
|
expect($backupItem->policy_identifier)->toBe($policy->external_id);
|
||||||
|
expect($backupItem->metadata['scope_tag_ids'] ?? null)->toBe(['st-1']);
|
||||||
|
expect($backupItem->metadata['scope_tag_names'] ?? null)->toBe(['Tag 1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore run wizard can be prefilled from query params for policy version backup set', function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-policy-version-prefill',
|
||||||
|
'name' => 'Tenant',
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'policy-2',
|
||||||
|
'policy_type' => 'settingsCatalogPolicy',
|
||||||
|
'display_name' => 'Settings Catalog',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version = PolicyVersion::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => now(),
|
||||||
|
'snapshot' => ['id' => $policy->external_id],
|
||||||
|
'assignments' => [[
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'groupId' => 'source-group-1',
|
||||||
|
'group_display_name' => 'Source Group',
|
||||||
|
],
|
||||||
|
'intent' => 'apply',
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Policy Version Restore',
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 1,
|
||||||
|
'completed_at' => now(),
|
||||||
|
'metadata' => [
|
||||||
|
'source' => 'policy_version',
|
||||||
|
'policy_version_id' => $version->id,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupItem = BackupItem::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'policy_version_id' => $version->id,
|
||||||
|
'policy_identifier' => $policy->external_id,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'payload' => $version->snapshot ?? [],
|
||||||
|
'assignments' => $version->assignments,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->mock(GroupResolver::class, function (MockInterface $mock) {
|
||||||
|
$mock->shouldReceive('resolveGroupIds')
|
||||||
|
->andReturnUsing(function (array $groupIds): array {
|
||||||
|
return collect($groupIds)
|
||||||
|
->mapWithKeys(fn (string $id) => [$id => [
|
||||||
|
'id' => $id,
|
||||||
|
'displayName' => null,
|
||||||
|
'orphaned' => true,
|
||||||
|
]])
|
||||||
|
->all();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Livewire::withQueryParams([
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'scope_mode' => 'selected',
|
||||||
|
'backup_item_ids' => [$backupItem->id],
|
||||||
|
])->test(CreateRestoreRun::class);
|
||||||
|
|
||||||
|
expect($component->get('data.backup_set_id'))->toBe($backupSet->id);
|
||||||
|
expect($component->get('data.scope_mode'))->toBe('selected');
|
||||||
|
expect($component->get('data.backup_item_ids'))->toBe([$backupItem->id]);
|
||||||
|
|
||||||
|
$mapping = $component->get('data.group_mapping');
|
||||||
|
expect($mapping)->toBeArray();
|
||||||
|
expect(array_key_exists('source-group-1', $mapping))->toBeTrue();
|
||||||
|
expect($mapping['source-group-1'])->toBeNull();
|
||||||
|
|
||||||
|
$component
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->assertFormFieldVisible('group_mapping.source-group-1');
|
||||||
|
});
|
||||||
@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
@ -11,7 +12,7 @@
|
|||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
test('restore selection shows readable labels and descriptions', function () {
|
test('restore selection options are grouped and filter ignored policies', function () {
|
||||||
$tenant = Tenant::factory()->create(['status' => 'active']);
|
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
@ -22,6 +23,13 @@
|
|||||||
'display_name' => 'Policy Display',
|
'display_name' => 'Policy Display',
|
||||||
'platform' => 'windows',
|
'platform' => 'windows',
|
||||||
]);
|
]);
|
||||||
|
$previewOnlyPolicy = Policy::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'policy-preview-only',
|
||||||
|
'policy_type' => 'conditionalAccessPolicy',
|
||||||
|
'display_name' => 'Conditional Access Policy',
|
||||||
|
'platform' => 'all',
|
||||||
|
]);
|
||||||
$ignoredPolicy = Policy::factory()->create([
|
$ignoredPolicy = Policy::factory()->create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'external_id' => 'policy-ignored',
|
'external_id' => 'policy-ignored',
|
||||||
@ -32,10 +40,10 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$backupSet = BackupSet::factory()->for($tenant)->create([
|
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||||
'item_count' => 2,
|
'item_count' => 4,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
BackupItem::factory()
|
$policyItem = BackupItem::factory()
|
||||||
->for($tenant)
|
->for($tenant)
|
||||||
->for($backupSet)
|
->for($backupSet)
|
||||||
->state([
|
->state([
|
||||||
@ -47,7 +55,7 @@
|
|||||||
])
|
])
|
||||||
->create();
|
->create();
|
||||||
|
|
||||||
BackupItem::factory()
|
$ignoredPolicyItem = BackupItem::factory()
|
||||||
->for($tenant)
|
->for($tenant)
|
||||||
->for($backupSet)
|
->for($backupSet)
|
||||||
->state([
|
->state([
|
||||||
@ -59,7 +67,7 @@
|
|||||||
])
|
])
|
||||||
->create();
|
->create();
|
||||||
|
|
||||||
BackupItem::factory()
|
$scopeTagItem = BackupItem::factory()
|
||||||
->for($tenant)
|
->for($tenant)
|
||||||
->for($backupSet)
|
->for($backupSet)
|
||||||
->state([
|
->state([
|
||||||
@ -77,6 +85,18 @@
|
|||||||
])
|
])
|
||||||
->create();
|
->create();
|
||||||
|
|
||||||
|
$previewOnlyItem = BackupItem::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->for($backupSet)
|
||||||
|
->state([
|
||||||
|
'policy_id' => $previewOnlyPolicy->id,
|
||||||
|
'policy_identifier' => $previewOnlyPolicy->external_id,
|
||||||
|
'policy_type' => $previewOnlyPolicy->policy_type,
|
||||||
|
'platform' => $previewOnlyPolicy->platform,
|
||||||
|
'payload' => ['id' => $previewOnlyPolicy->external_id],
|
||||||
|
])
|
||||||
|
->create();
|
||||||
|
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
@ -84,13 +104,33 @@
|
|||||||
->fillForm([
|
->fillForm([
|
||||||
'backup_set_id' => $backupSet->id,
|
'backup_set_id' => $backupSet->id,
|
||||||
])
|
])
|
||||||
->assertSee('Policy Display')
|
->goToNextWizardStep()
|
||||||
->assertDontSee('Ignored Policy')
|
->fillForm([
|
||||||
->assertSee('Scope Tag Alpha')
|
'scope_mode' => 'selected',
|
||||||
->assertSee('Settings Catalog Policy')
|
])
|
||||||
->assertSee('Scope Tag')
|
->assertFormFieldVisible('backup_item_ids')
|
||||||
->assertSee('restore: enabled')
|
|
||||||
->assertSee('id: policy-1')
|
|
||||||
->assertSee('id: tag-1')
|
|
||||||
->assertSee('Include foundations');
|
->assertSee('Include foundations');
|
||||||
|
|
||||||
|
$method = new ReflectionMethod(RestoreRunResource::class, 'restoreItemGroupedOptions');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
$groupedOptions = $method->invoke(null, $backupSet->id);
|
||||||
|
|
||||||
|
expect($groupedOptions)->toHaveKey('Configuration • Settings Catalog Policy • windows');
|
||||||
|
expect($groupedOptions)->toHaveKey('Foundations • Scope Tag • all');
|
||||||
|
expect($groupedOptions)->toHaveKey('Conditional Access • Conditional Access • all • preview-only');
|
||||||
|
|
||||||
|
$flattenedOptions = collect($groupedOptions)
|
||||||
|
->reduce(fn (array $carry, array $options): array => $carry + $options, []);
|
||||||
|
|
||||||
|
expect($flattenedOptions)->toHaveKey($policyItem->id);
|
||||||
|
expect($flattenedOptions[$policyItem->id])->toBe('Policy Display');
|
||||||
|
|
||||||
|
expect($flattenedOptions)->not->toHaveKey($ignoredPolicyItem->id);
|
||||||
|
|
||||||
|
expect($flattenedOptions)->toHaveKey($scopeTagItem->id);
|
||||||
|
expect($flattenedOptions[$scopeTagItem->id])->toBe('Scope Tag Alpha');
|
||||||
|
|
||||||
|
expect($flattenedOptions)->toHaveKey($previewOnlyItem->id);
|
||||||
|
expect($flattenedOptions[$previewOnlyItem->id])->toBe('Conditional Access Policy');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -77,12 +77,23 @@
|
|||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
Livewire::test(CreateRestoreRun::class)
|
$component = Livewire::test(CreateRestoreRun::class)
|
||||||
->fillForm([
|
->fillForm([
|
||||||
'backup_set_id' => $backupSet->id,
|
'backup_set_id' => $backupSet->id,
|
||||||
'backup_item_ids' => [$backupItem->id],
|
|
||||||
])
|
])
|
||||||
->assertFormFieldVisible('group_mapping.source-group-1');
|
->goToNextWizardStep()
|
||||||
|
->fillForm([
|
||||||
|
'scope_mode' => 'selected',
|
||||||
|
'backup_item_ids' => [$backupItem->id],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$mapping = $component->get('data.group_mapping');
|
||||||
|
|
||||||
|
expect($mapping)->toBeArray();
|
||||||
|
expect(array_key_exists('source-group-1', $mapping))->toBeTrue();
|
||||||
|
expect($mapping['source-group-1'])->toBeNull();
|
||||||
|
|
||||||
|
$component->assertFormFieldVisible('group_mapping.source-group-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('restore wizard persists group mapping selections', function () {
|
test('restore wizard persists group mapping selections', function () {
|
||||||
@ -150,12 +161,19 @@
|
|||||||
Livewire::test(CreateRestoreRun::class)
|
Livewire::test(CreateRestoreRun::class)
|
||||||
->fillForm([
|
->fillForm([
|
||||||
'backup_set_id' => $backupSet->id,
|
'backup_set_id' => $backupSet->id,
|
||||||
|
])
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->fillForm([
|
||||||
|
'scope_mode' => 'selected',
|
||||||
'backup_item_ids' => [$backupItem->id],
|
'backup_item_ids' => [$backupItem->id],
|
||||||
'group_mapping' => [
|
'group_mapping' => [
|
||||||
'source-group-1' => 'target-group-1',
|
'source-group-1' => 'target-group-1',
|
||||||
],
|
],
|
||||||
'is_dry_run' => true,
|
|
||||||
])
|
])
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->callFormComponentAction('preview_diffs', 'run_restore_preview')
|
||||||
|
->goToNextWizardStep()
|
||||||
->call('create')
|
->call('create')
|
||||||
->assertHasNoFormErrors();
|
->assertHasNoFormErrors();
|
||||||
|
|
||||||
|
|||||||
132
tests/Feature/RestorePreviewDiffWizardTest.php
Normal file
132
tests/Feature/RestorePreviewDiffWizardTest.php
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\RestoreRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
putenv('INTUNE_TENANT_ID');
|
||||||
|
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore wizard generates a normalized preview diff summary and persists it', function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-1',
|
||||||
|
'name' => 'Tenant One',
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'policy-1',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'display_name' => 'Device Config Policy',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => now()->subDay(),
|
||||||
|
'snapshot' => [
|
||||||
|
'foo' => 'current',
|
||||||
|
],
|
||||||
|
'metadata' => [],
|
||||||
|
'assignments' => [],
|
||||||
|
'scope_tags' => [
|
||||||
|
'ids' => ['tag-2'],
|
||||||
|
'names' => ['Tag Two'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Backup',
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
BackupItem::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'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' => 'group-1',
|
||||||
|
],
|
||||||
|
'intent' => 'apply',
|
||||||
|
]],
|
||||||
|
'metadata' => [
|
||||||
|
'scope_tag_ids' => ['tag-1'],
|
||||||
|
'scope_tag_names' => ['Tag One'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Livewire::test(CreateRestoreRun::class)
|
||||||
|
->fillForm([
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
])
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->callFormComponentAction('preview_diffs', 'run_restore_preview');
|
||||||
|
|
||||||
|
$summary = $component->get('data.preview_summary');
|
||||||
|
$diffs = $component->get('data.preview_diffs');
|
||||||
|
|
||||||
|
expect($summary)->toBeArray();
|
||||||
|
expect($summary['policies_total'] ?? null)->toBe(1);
|
||||||
|
expect($summary['policies_changed'] ?? null)->toBe(1);
|
||||||
|
expect($summary['assignments_changed'] ?? null)->toBe(1);
|
||||||
|
expect($summary['scope_tags_changed'] ?? null)->toBe(1);
|
||||||
|
|
||||||
|
expect($diffs)->toBeArray();
|
||||||
|
expect($diffs)->not->toBeEmpty();
|
||||||
|
|
||||||
|
$first = $diffs[0] ?? [];
|
||||||
|
expect($first)->toBeArray();
|
||||||
|
expect($first['action'] ?? null)->toBe('update');
|
||||||
|
expect($first['assignments_changed'] ?? null)->toBeTrue();
|
||||||
|
expect($first['scope_tags_changed'] ?? null)->toBeTrue();
|
||||||
|
expect($first['diff']['summary']['changed'] ?? null)->toBe(1);
|
||||||
|
|
||||||
|
$component
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->call('create')
|
||||||
|
->assertHasNoFormErrors();
|
||||||
|
|
||||||
|
$run = RestoreRun::query()->latest('id')->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run->metadata)->toHaveKeys([
|
||||||
|
'preview_summary',
|
||||||
|
'preview_diffs',
|
||||||
|
'preview_ran_at',
|
||||||
|
]);
|
||||||
|
expect($run->metadata['preview_summary']['policies_changed'] ?? null)->toBe(1);
|
||||||
|
});
|
||||||
222
tests/Feature/RestoreRiskChecksWizardTest.php
Normal file
222
tests/Feature/RestoreRiskChecksWizardTest.php
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\RestoreRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Graph\GroupResolver;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
putenv('INTUNE_TENANT_ID');
|
||||||
|
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore wizard can run safety checks and persists results on the restore run', function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-1',
|
||||||
|
'name' => 'Tenant One',
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'policy-1',
|
||||||
|
'policy_type' => 'settingsCatalogPolicy',
|
||||||
|
'display_name' => 'Settings Catalog',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Backup',
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupItem = BackupItem::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'policy_identifier' => $policy->external_id,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => now(),
|
||||||
|
'payload' => ['id' => $policy->external_id],
|
||||||
|
'assignments' => [[
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'groupId' => 'source-group-1',
|
||||||
|
'group_display_name' => 'Source Group',
|
||||||
|
],
|
||||||
|
'intent' => 'apply',
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->mock(GroupResolver::class, function (MockInterface $mock) {
|
||||||
|
$mock->shouldReceive('resolveGroupIds')
|
||||||
|
->andReturnUsing(function (array $groupIds): array {
|
||||||
|
return collect($groupIds)
|
||||||
|
->mapWithKeys(fn (string $id) => [$id => [
|
||||||
|
'id' => $id,
|
||||||
|
'displayName' => null,
|
||||||
|
'orphaned' => true,
|
||||||
|
]])
|
||||||
|
->all();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Livewire::test(CreateRestoreRun::class)
|
||||||
|
->fillForm([
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
])
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->fillForm([
|
||||||
|
'scope_mode' => 'selected',
|
||||||
|
'backup_item_ids' => [$backupItem->id],
|
||||||
|
])
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->assertFormComponentActionVisible('check_results', 'run_restore_checks')
|
||||||
|
->callFormComponentAction('check_results', 'run_restore_checks');
|
||||||
|
|
||||||
|
$summary = $component->get('data.check_summary');
|
||||||
|
$results = $component->get('data.check_results');
|
||||||
|
|
||||||
|
expect($summary)->toBeArray();
|
||||||
|
expect($summary['blocking'] ?? null)->toBe(1);
|
||||||
|
expect($summary['has_blockers'] ?? null)->toBeTrue();
|
||||||
|
|
||||||
|
expect($results)->toBeArray();
|
||||||
|
expect($results)->not->toBeEmpty();
|
||||||
|
|
||||||
|
$assignmentCheck = collect($results)->firstWhere('code', 'assignment_groups');
|
||||||
|
expect($assignmentCheck)->toBeArray();
|
||||||
|
expect($assignmentCheck['severity'] ?? null)->toBe('blocking');
|
||||||
|
|
||||||
|
$unmappedGroups = $assignmentCheck['meta']['unmapped'] ?? [];
|
||||||
|
expect($unmappedGroups)->toBeArray();
|
||||||
|
expect($unmappedGroups[0]['id'] ?? null)->toBe('source-group-1');
|
||||||
|
|
||||||
|
$checksRanAt = $component->get('data.checks_ran_at');
|
||||||
|
expect($checksRanAt)->toBeString();
|
||||||
|
|
||||||
|
$component
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->callFormComponentAction('preview_diffs', 'run_restore_preview')
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->call('create')
|
||||||
|
->assertHasNoFormErrors();
|
||||||
|
|
||||||
|
$run = RestoreRun::query()->latest('id')->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run->metadata)->toHaveKeys([
|
||||||
|
'check_summary',
|
||||||
|
'check_results',
|
||||||
|
'checks_ran_at',
|
||||||
|
]);
|
||||||
|
expect($run->metadata['check_summary']['blocking'] ?? null)->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore wizard treats skipped orphaned groups as a warning instead of a blocker', function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-1',
|
||||||
|
'name' => 'Tenant One',
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'policy-1',
|
||||||
|
'policy_type' => 'settingsCatalogPolicy',
|
||||||
|
'display_name' => 'Settings Catalog',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Backup',
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupItem = BackupItem::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'policy_identifier' => $policy->external_id,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => now(),
|
||||||
|
'payload' => ['id' => $policy->external_id],
|
||||||
|
'assignments' => [[
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'groupId' => 'source-group-1',
|
||||||
|
'group_display_name' => 'Source Group',
|
||||||
|
],
|
||||||
|
'intent' => 'apply',
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->mock(GroupResolver::class, function (MockInterface $mock) {
|
||||||
|
$mock->shouldReceive('resolveGroupIds')
|
||||||
|
->andReturnUsing(function (array $groupIds): array {
|
||||||
|
return collect($groupIds)
|
||||||
|
->mapWithKeys(fn (string $id) => [$id => [
|
||||||
|
'id' => $id,
|
||||||
|
'displayName' => null,
|
||||||
|
'orphaned' => true,
|
||||||
|
]])
|
||||||
|
->all();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Livewire::test(CreateRestoreRun::class)
|
||||||
|
->fillForm([
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
])
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->fillForm([
|
||||||
|
'scope_mode' => 'selected',
|
||||||
|
'backup_item_ids' => [$backupItem->id],
|
||||||
|
])
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->set('data.group_mapping', (object) [
|
||||||
|
'source-group-1' => 'SKIP',
|
||||||
|
])
|
||||||
|
->callFormComponentAction('check_results', 'run_restore_checks');
|
||||||
|
|
||||||
|
$summary = $component->get('data.check_summary');
|
||||||
|
$results = $component->get('data.check_results');
|
||||||
|
|
||||||
|
expect($summary)->toBeArray();
|
||||||
|
expect($summary['blocking'] ?? null)->toBe(0);
|
||||||
|
expect($summary['has_blockers'] ?? null)->toBeFalse();
|
||||||
|
expect($summary['warning'] ?? null)->toBe(1);
|
||||||
|
|
||||||
|
$assignmentCheck = collect($results)->firstWhere('code', 'assignment_groups');
|
||||||
|
expect($assignmentCheck)->toBeArray();
|
||||||
|
expect($assignmentCheck['severity'] ?? null)->toBe('warning');
|
||||||
|
|
||||||
|
$skippedGroups = $assignmentCheck['meta']['skipped'] ?? [];
|
||||||
|
expect($skippedGroups)->toBeArray();
|
||||||
|
expect($skippedGroups[0]['id'] ?? null)->toBe('source-group-1');
|
||||||
|
});
|
||||||
165
tests/Feature/RestoreRunWizardExecuteTest.php
Normal file
165
tests/Feature/RestoreRunWizardExecuteTest.php
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
||||||
|
use App\Jobs\ExecuteRestoreRunJob;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\RestoreRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\RestoreRunStatus;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
putenv('INTUNE_TENANT_ID');
|
||||||
|
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore run wizard blocks execution when confirmations are missing', function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-1',
|
||||||
|
'name' => 'Tenant One',
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'policy-1',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'display_name' => 'Device Config Policy',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Backup',
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupItem = BackupItem::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'policy_identifier' => $policy->external_id,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'payload' => ['id' => $policy->external_id],
|
||||||
|
'metadata' => [
|
||||||
|
'displayName' => 'Backup Policy',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'tester@example.com',
|
||||||
|
'name' => 'Tester',
|
||||||
|
]);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(CreateRestoreRun::class)
|
||||||
|
->fillForm([
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
])
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->fillForm([
|
||||||
|
'scope_mode' => 'selected',
|
||||||
|
'backup_item_ids' => [$backupItem->id],
|
||||||
|
])
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->callFormComponentAction('check_results', 'run_restore_checks')
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->callFormComponentAction('preview_diffs', 'run_restore_preview')
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->fillForm([
|
||||||
|
'is_dry_run' => false,
|
||||||
|
])
|
||||||
|
->call('create')
|
||||||
|
->assertHasFormErrors(['acknowledged_impact', 'tenant_confirm']);
|
||||||
|
|
||||||
|
expect(RestoreRun::count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore run wizard queues execution when gates are satisfied', function () {
|
||||||
|
Bus::fake();
|
||||||
|
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-2',
|
||||||
|
'name' => 'Tenant Two',
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'policy-2',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'display_name' => 'Device Config Policy',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Backup',
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupItem = BackupItem::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'policy_identifier' => $policy->external_id,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'payload' => ['id' => $policy->external_id],
|
||||||
|
'metadata' => [
|
||||||
|
'displayName' => 'Backup Policy',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'executor@example.com',
|
||||||
|
'name' => 'Executor',
|
||||||
|
]);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(CreateRestoreRun::class)
|
||||||
|
->fillForm([
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
])
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->fillForm([
|
||||||
|
'scope_mode' => 'selected',
|
||||||
|
'backup_item_ids' => [$backupItem->id],
|
||||||
|
])
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->callFormComponentAction('check_results', 'run_restore_checks')
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->callFormComponentAction('preview_diffs', 'run_restore_preview')
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->fillForm([
|
||||||
|
'is_dry_run' => false,
|
||||||
|
'acknowledged_impact' => true,
|
||||||
|
'tenant_confirm' => 'Tenant Two',
|
||||||
|
])
|
||||||
|
->call('create')
|
||||||
|
->assertHasNoFormErrors();
|
||||||
|
|
||||||
|
$run = RestoreRun::query()->latest('id')->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run->status)->toBe(RestoreRunStatus::Queued->value);
|
||||||
|
expect($run->is_dry_run)->toBeFalse();
|
||||||
|
expect($run->metadata['confirmed_by'] ?? null)->toBe('executor@example.com');
|
||||||
|
expect($run->metadata['confirmed_at'] ?? null)->toBeString();
|
||||||
|
|
||||||
|
Bus::assertDispatched(ExecuteRestoreRunJob::class);
|
||||||
|
});
|
||||||
86
tests/Feature/RestoreRunWizardMetadataTest.php
Normal file
86
tests/Feature/RestoreRunWizardMetadataTest.php
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\RestoreRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
putenv('INTUNE_TENANT_ID');
|
||||||
|
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore run stores wizard audit metadata and preserves it on completion', function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-1',
|
||||||
|
'name' => 'Tenant One',
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
$backupSet = BackupSet::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Backup',
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupItem = BackupItem::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'policy_id' => null,
|
||||||
|
'policy_identifier' => 'policy-1',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'platform' => 'windows',
|
||||||
|
'payload' => ['id' => 'policy-1'],
|
||||||
|
'metadata' => [
|
||||||
|
'displayName' => 'Backup Policy One',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'tester@example.com',
|
||||||
|
'name' => 'Tester',
|
||||||
|
]);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(CreateRestoreRun::class)
|
||||||
|
->fillForm([
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
])
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->fillForm([
|
||||||
|
'scope_mode' => 'selected',
|
||||||
|
'backup_item_ids' => [$backupItem->id],
|
||||||
|
])
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->callFormComponentAction('preview_diffs', 'run_restore_preview')
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->call('create')
|
||||||
|
->assertHasNoFormErrors();
|
||||||
|
|
||||||
|
$run = RestoreRun::query()->latest('id')->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run->metadata)->toHaveKeys([
|
||||||
|
'scope_mode',
|
||||||
|
'environment',
|
||||||
|
'highlander_label',
|
||||||
|
'failed',
|
||||||
|
'non_applied',
|
||||||
|
'total',
|
||||||
|
'foundations_skipped',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($run->metadata['scope_mode'])->toBe('selected');
|
||||||
|
expect($run->metadata['environment'])->toBe('test');
|
||||||
|
expect($run->metadata['highlander_label'])->toBe('Tenant One');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user