feat/012-windows-update-rings #18
@ -6,6 +6,8 @@
|
||||
use App\Jobs\BulkPolicyVersionForceDeleteJob;
|
||||
use App\Jobs\BulkPolicyVersionPruneJob;
|
||||
use App\Jobs\BulkPolicyVersionRestoreJob;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\BulkOperationService;
|
||||
@ -13,6 +15,7 @@
|
||||
use App\Services\Intune\PolicyNormalizer;
|
||||
use App\Services\Intune\VersionDiff;
|
||||
use BackedEnum;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
@ -183,6 +186,96 @@ public static function table(Table $table): Table
|
||||
->url(fn (PolicyVersion $record) => static::getUrl('view', ['record' => $record]))
|
||||
->openUrlInNewTab(false),
|
||||
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')
|
||||
->label('Archive')
|
||||
->color('danger')
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Jobs\BulkRestoreRunDeleteJob;
|
||||
use App\Jobs\BulkRestoreRunForceDeleteJob;
|
||||
use App\Jobs\BulkRestoreRunRestoreJob;
|
||||
use App\Jobs\ExecuteRestoreRunJob;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\RestoreRun;
|
||||
@ -13,7 +14,11 @@
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
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\Support\RestoreRunStatus;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\ActionGroup;
|
||||
@ -26,13 +31,16 @@
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Filament\Schemas\Components\Wizard\Step;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\TrashedFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use UnitEnum;
|
||||
|
||||
class RestoreRunResource extends Resource
|
||||
@ -69,8 +77,10 @@ public static function form(Schema $schema): Schema
|
||||
})
|
||||
->reactive()
|
||||
->afterStateUpdated(function (Set $set): void {
|
||||
$set('backup_item_ids', []);
|
||||
$set('scope_mode', 'all');
|
||||
$set('backup_item_ids', null);
|
||||
$set('group_mapping', []);
|
||||
$set('is_dry_run', true);
|
||||
})
|
||||
->required(),
|
||||
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
|
||||
{
|
||||
return $table
|
||||
@ -533,11 +1028,72 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
||||
];
|
||||
}
|
||||
|
||||
static $cache = [];
|
||||
$cacheKey = $tenant->getKey().':'.$backupSetId;
|
||||
$cacheKey = sprintf('restore_run_item_options:%s:%s', $tenant->getKey(), $backupSetId);
|
||||
|
||||
if (isset($cache[$cacheKey])) {
|
||||
return $cache[$cacheKey];
|
||||
return Cache::store('array')->rememberForever($cacheKey, function () use ($backupSetId, $tenant): array {
|
||||
$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()
|
||||
@ -548,49 +1104,40 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
||||
->orWhereDoesntHave('policy')
|
||||
->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()
|
||||
->sortBy(function (BackupItem $item) {
|
||||
$meta = static::typeMeta($item->policy_type);
|
||||
$category = $meta['category'] ?? 'Policies';
|
||||
$categoryKey = $category === 'Foundations' ? 'zz-'.$category : $category;
|
||||
$typeLabel = $meta['label'] ?? $item->policy_type;
|
||||
$platform = $item->platform ?? $meta['platform'] ?? null;
|
||||
$name = strtolower($item->resolvedDisplayName());
|
||||
|
||||
return strtolower($categoryKey.'-'.$name);
|
||||
return strtolower($categoryKey.'-'.$typeLabel.'-'.$platform.'-'.$name);
|
||||
});
|
||||
|
||||
$options = [];
|
||||
$descriptions = [];
|
||||
$groups = [];
|
||||
|
||||
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;
|
||||
$platform = $item->platform ?? $meta['platform'] ?? 'all';
|
||||
$restoreMode = $meta['restore'] ?? 'enabled';
|
||||
|
||||
$options[$item->id] = $displayName;
|
||||
|
||||
$parts = array_filter([
|
||||
$groupLabel = implode(' • ', 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,
|
||||
]);
|
||||
$restoreMode === 'preview-only' ? 'preview-only' : null,
|
||||
]));
|
||||
|
||||
$descriptions[$item->id] = implode(' • ', $parts);
|
||||
$groups[$groupLabel] ??= [];
|
||||
$groups[$groupLabel][$item->id] = $item->resolvedDisplayName();
|
||||
}
|
||||
|
||||
return $cache[$cacheKey] = [
|
||||
'options' => $options,
|
||||
'descriptions' => $descriptions,
|
||||
];
|
||||
return $groups;
|
||||
}
|
||||
|
||||
public static function createRestoreRun(array $data): RestoreRun
|
||||
@ -608,15 +1155,170 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
/** @var RestoreService $service */
|
||||
$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,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: $data['backup_item_ids'] ?? null,
|
||||
dryRun: (bool) ($data['is_dry_run'] ?? true),
|
||||
actorEmail: auth()->user()?->email,
|
||||
actorName: auth()->user()?->name,
|
||||
groupMapping: $data['group_mapping'] ?? [],
|
||||
action: 'restore.queued',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'restore_run_id' => $restoreRun->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
],
|
||||
],
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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>
|
||||
*/
|
||||
|
||||
@ -3,13 +3,118 @@
|
||||
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
||||
|
||||
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 Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CreateRestoreRun extends CreateRecord
|
||||
{
|
||||
use HasWizard;
|
||||
|
||||
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
|
||||
{
|
||||
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;
|
||||
|
||||
use App\Support\RestoreRunStatus;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@ -35,17 +37,30 @@ public function backupSet(): BelongsTo
|
||||
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
|
||||
{
|
||||
$status = strtolower(trim((string) $this->status));
|
||||
$status = str_replace([' ', '-'], '_', $status);
|
||||
$status = RestoreRunStatus::fromString($this->status);
|
||||
|
||||
return in_array($status, ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed'], true);
|
||||
return $status?->isDeletable() ?? false;
|
||||
}
|
||||
|
||||
// Group mapping helpers
|
||||
|
||||
@ -104,13 +104,17 @@ public function makeCurrent(): void
|
||||
DB::transaction(function () {
|
||||
static::activeQuery()->update(['is_current' => false]);
|
||||
|
||||
$this->forceFill(['is_current' => true])->save();
|
||||
static::query()
|
||||
->whereKey($this->getKey())
|
||||
->update(['is_current' => true]);
|
||||
});
|
||||
|
||||
$this->forceFill(['is_current' => true]);
|
||||
}
|
||||
|
||||
public static function current(): self
|
||||
{
|
||||
$envTenantId = env('INTUNE_TENANT_ID') ?: null;
|
||||
$envTenantId = getenv('INTUNE_TENANT_ID') ?: null;
|
||||
|
||||
if ($envTenantId) {
|
||||
$tenant = static::activeQuery()
|
||||
|
||||
@ -10,6 +10,9 @@
|
||||
use App\Services\Intune\DeviceConfigurationPolicyNormalizer;
|
||||
use App\Services\Intune\GroupPolicyConfigurationNormalizer;
|
||||
use App\Services\Intune\SettingsCatalogPolicyNormalizer;
|
||||
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
|
||||
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
|
||||
use App\Services\Intune\WindowsUpdateRingNormalizer;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@ -40,6 +43,9 @@ public function register(): void
|
||||
DeviceConfigurationPolicyNormalizer::class,
|
||||
GroupPolicyConfigurationNormalizer::class,
|
||||
SettingsCatalogPolicyNormalizer::class,
|
||||
WindowsFeatureUpdateProfileNormalizer::class,
|
||||
WindowsQualityUpdateProfileNormalizer::class,
|
||||
WindowsUpdateRingNormalizer::class,
|
||||
],
|
||||
'policy-type-normalizers'
|
||||
);
|
||||
|
||||
@ -77,6 +77,16 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
|
||||
$metadata = Arr::except($response->data, ['payload']);
|
||||
$metadataWarnings = $metadata['warnings'] ?? [];
|
||||
|
||||
if ($policy->policy_type === 'windowsUpdateRing') {
|
||||
[$payload, $metadata] = $this->hydrateWindowsUpdateRing(
|
||||
tenantIdentifier: $tenantIdentifier,
|
||||
tenant: $tenant,
|
||||
policyId: $policy->external_id,
|
||||
payload: is_array($payload) ? $payload : [],
|
||||
metadata: $metadata,
|
||||
);
|
||||
}
|
||||
|
||||
if ($policy->policy_type === 'settingsCatalogPolicy') {
|
||||
[$payload, $metadata] = $this->hydrateSettingsCatalog(
|
||||
tenantIdentifier: $tenantIdentifier,
|
||||
@ -152,6 +162,57 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate Windows Update Ring payload via derived type cast to capture
|
||||
* windowsUpdateForBusinessConfiguration-specific properties.
|
||||
*
|
||||
* @return array{0:array,1:array}
|
||||
*/
|
||||
private function hydrateWindowsUpdateRing(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
|
||||
{
|
||||
$odataType = $payload['@odata.type'] ?? null;
|
||||
$castSegment = $this->deriveTypeCastSegment($odataType);
|
||||
|
||||
if ($castSegment === null) {
|
||||
$metadata['properties_hydration'] = 'skipped';
|
||||
|
||||
return [$payload, $metadata];
|
||||
}
|
||||
|
||||
$castPath = sprintf('deviceManagement/deviceConfigurations/%s/%s', urlencode($policyId), $castSegment);
|
||||
|
||||
$response = $this->graphClient->request('GET', $castPath, [
|
||||
'tenant' => $tenantIdentifier,
|
||||
'client_id' => $tenant->app_client_id,
|
||||
'client_secret' => $tenant->app_client_secret,
|
||||
]);
|
||||
|
||||
if ($response->failed() || ! is_array($response->data)) {
|
||||
$metadata['properties_hydration'] = 'failed';
|
||||
|
||||
return [$payload, $metadata];
|
||||
}
|
||||
|
||||
$metadata['properties_hydration'] = 'complete';
|
||||
|
||||
return [array_merge($payload, $response->data), $metadata];
|
||||
}
|
||||
|
||||
private function deriveTypeCastSegment(mixed $odataType): ?string
|
||||
{
|
||||
if (! is_string($odataType) || $odataType === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! str_starts_with($odataType, '#')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$segment = ltrim($odataType, '#');
|
||||
|
||||
return $segment !== '' ? $segment : null;
|
||||
}
|
||||
|
||||
private function isMetadataOnlyPolicyType(string $policyType): bool
|
||||
{
|
||||
foreach (config('tenantpilot.supported_policy_types', []) as $type) {
|
||||
|
||||
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);
|
||||
|
||||
if ($selectedItemIds === []) {
|
||||
$selectedItemIds = null;
|
||||
}
|
||||
|
||||
$items = $this->loadItems($backupSet, $selectedItemIds);
|
||||
|
||||
[$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(
|
||||
Tenant $tenant,
|
||||
BackupSet $backupSet,
|
||||
@ -188,26 +231,65 @@ public function execute(
|
||||
?string $actorEmail = null,
|
||||
?string $actorName = null,
|
||||
array $groupMapping = [],
|
||||
?RestoreRun $existingRun = null,
|
||||
): RestoreRun {
|
||||
$this->assertActiveContext($tenant, $backupSet);
|
||||
|
||||
if ($selectedItemIds === []) {
|
||||
$selectedItemIds = null;
|
||||
}
|
||||
|
||||
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
||||
$items = $this->loadItems($backupSet, $selectedItemIds);
|
||||
[$foundationItems, $policyItems] = $this->splitItems($items);
|
||||
$preview = $this->preview($tenant, $backupSet, $selectedItemIds);
|
||||
|
||||
$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' => [],
|
||||
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
|
||||
]);
|
||||
$wizardMetadata = [
|
||||
'scope_mode' => $selectedItemIds === null ? 'all' : 'selected',
|
||||
'environment' => app()->environment('production') ? 'prod' : 'test',
|
||||
'highlander_label' => (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()),
|
||||
];
|
||||
|
||||
if ($existingRun !== null) {
|
||||
if ($existingRun->tenant_id !== $tenant->id) {
|
||||
throw new \InvalidArgumentException('Restore run does not belong to the provided tenant.');
|
||||
}
|
||||
|
||||
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 !== []) {
|
||||
$this->auditLogger->log(
|
||||
@ -473,6 +555,23 @@ public function execute(
|
||||
$payload,
|
||||
$graphOptions + ['method' => $updateMethod]
|
||||
);
|
||||
} elseif ($item->policy_type === 'windowsUpdateRing') {
|
||||
$odataType = $this->resolvePayloadString($originalPayload, ['@odata.type']);
|
||||
$castSegment = $odataType && str_starts_with($odataType, '#')
|
||||
? ltrim($odataType, '#')
|
||||
: 'microsoft.graph.windowsUpdateForBusinessConfiguration';
|
||||
|
||||
$updatePath = sprintf(
|
||||
'deviceManagement/deviceConfigurations/%s/%s',
|
||||
urlencode($item->policy_identifier),
|
||||
$castSegment,
|
||||
);
|
||||
|
||||
$response = $this->graphClient->request(
|
||||
$updateMethod,
|
||||
$updatePath,
|
||||
['json' => $payload] + Arr::except($graphOptions, ['platform'])
|
||||
);
|
||||
} else {
|
||||
$response = $this->graphClient->applyPolicy(
|
||||
$item->policy_type,
|
||||
@ -740,12 +839,12 @@ public function execute(
|
||||
'status' => $status,
|
||||
'results' => $results,
|
||||
'completed_at' => CarbonImmutable::now(),
|
||||
'metadata' => [
|
||||
'metadata' => array_merge($restoreRun->metadata ?? [], [
|
||||
'failed' => $hardFailures,
|
||||
'non_applied' => $nonApplied,
|
||||
'total' => $totalCount,
|
||||
'foundations_skipped' => $foundationSkipped,
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->auditLogger->log(
|
||||
|
||||
107
app/Services/Intune/WindowsFeatureUpdateProfileNormalizer.php
Normal file
107
app/Services/Intune/WindowsFeatureUpdateProfileNormalizer.php
Normal file
@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Intune;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class WindowsFeatureUpdateProfileNormalizer implements PolicyTypeNormalizer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DefaultPolicyNormalizer $defaultNormalizer,
|
||||
) {}
|
||||
|
||||
public function supports(string $policyType): bool
|
||||
{
|
||||
return $policyType === 'windowsFeatureUpdateProfile';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>}
|
||||
*/
|
||||
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
|
||||
{
|
||||
$snapshot = $snapshot ?? [];
|
||||
$normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform);
|
||||
|
||||
if ($snapshot === []) {
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
$normalized['settings'][] = $this->buildFeatureUpdateBlock($snapshot);
|
||||
$normalized['settings'] = array_values(array_filter($normalized['settings']));
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
|
||||
{
|
||||
$snapshot = $snapshot ?? [];
|
||||
$normalized = $this->normalize($snapshot, $policyType, $platform);
|
||||
|
||||
return $this->defaultNormalizer->flattenNormalizedForDiff($normalized);
|
||||
}
|
||||
|
||||
private function buildFeatureUpdateBlock(array $snapshot): ?array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
$displayName = Arr::get($snapshot, 'displayName');
|
||||
|
||||
if (is_string($displayName) && $displayName !== '') {
|
||||
$entries[] = ['key' => 'Name', 'value' => $displayName];
|
||||
}
|
||||
|
||||
$version = Arr::get($snapshot, 'featureUpdateVersion');
|
||||
|
||||
if (is_string($version) && $version !== '') {
|
||||
$entries[] = ['key' => 'Feature update version', 'value' => $version];
|
||||
}
|
||||
|
||||
$rollout = Arr::get($snapshot, 'rolloutSettings');
|
||||
|
||||
if (is_array($rollout)) {
|
||||
$start = $this->formatDateTime($rollout['offerStartDateTimeInUTC'] ?? null);
|
||||
$end = $this->formatDateTime($rollout['offerEndDateTimeInUTC'] ?? null);
|
||||
$interval = $rollout['offerIntervalInDays'] ?? null;
|
||||
|
||||
if ($start !== null) {
|
||||
$entries[] = ['key' => 'Rollout start', 'value' => $start];
|
||||
}
|
||||
|
||||
if ($end !== null) {
|
||||
$entries[] = ['key' => 'Rollout end', 'value' => $end];
|
||||
}
|
||||
|
||||
if ($interval !== null) {
|
||||
$entries[] = ['key' => 'Rollout interval (days)', 'value' => $interval];
|
||||
}
|
||||
}
|
||||
|
||||
if ($entries === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'keyValue',
|
||||
'title' => 'Feature Update Profile',
|
||||
'entries' => $entries,
|
||||
];
|
||||
}
|
||||
|
||||
private function formatDateTime(mixed $value): ?string
|
||||
{
|
||||
if (! is_string($value) || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return CarbonImmutable::parse($value)->toDateTimeString();
|
||||
} catch (\Throwable) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Intune;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class WindowsQualityUpdateProfileNormalizer implements PolicyTypeNormalizer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DefaultPolicyNormalizer $defaultNormalizer,
|
||||
) {}
|
||||
|
||||
public function supports(string $policyType): bool
|
||||
{
|
||||
return $policyType === 'windowsQualityUpdateProfile';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>}
|
||||
*/
|
||||
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
|
||||
{
|
||||
$snapshot = $snapshot ?? [];
|
||||
$normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform);
|
||||
|
||||
if ($snapshot === []) {
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
$block = $this->buildQualityUpdateBlock($snapshot);
|
||||
|
||||
if ($block !== null) {
|
||||
$normalized['settings'][] = $block;
|
||||
$normalized['settings'] = array_values(array_filter($normalized['settings']));
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
|
||||
{
|
||||
$snapshot = $snapshot ?? [];
|
||||
$normalized = $this->normalize($snapshot, $policyType, $platform);
|
||||
|
||||
return $this->defaultNormalizer->flattenNormalizedForDiff($normalized);
|
||||
}
|
||||
|
||||
private function buildQualityUpdateBlock(array $snapshot): ?array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
$displayName = Arr::get($snapshot, 'displayName');
|
||||
|
||||
if (is_string($displayName) && $displayName !== '') {
|
||||
$entries[] = ['key' => 'Name', 'value' => $displayName];
|
||||
}
|
||||
|
||||
$release = Arr::get($snapshot, 'releaseDateDisplayName');
|
||||
|
||||
if (is_string($release) && $release !== '') {
|
||||
$entries[] = ['key' => 'Release', 'value' => $release];
|
||||
}
|
||||
|
||||
$content = Arr::get($snapshot, 'deployableContentDisplayName');
|
||||
|
||||
if (is_string($content) && $content !== '') {
|
||||
$entries[] = ['key' => 'Deployable content', 'value' => $content];
|
||||
}
|
||||
|
||||
if ($entries === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'keyValue',
|
||||
'title' => 'Quality Update Profile',
|
||||
'entries' => $entries,
|
||||
];
|
||||
}
|
||||
}
|
||||
137
app/Services/Intune/WindowsUpdateRingNormalizer.php
Normal file
137
app/Services/Intune/WindowsUpdateRingNormalizer.php
Normal file
@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Intune;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class WindowsUpdateRingNormalizer implements PolicyTypeNormalizer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DefaultPolicyNormalizer $defaultNormalizer,
|
||||
) {}
|
||||
|
||||
public function supports(string $policyType): bool
|
||||
{
|
||||
return $policyType === 'windowsUpdateRing';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>}
|
||||
*/
|
||||
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
|
||||
{
|
||||
$snapshot = $snapshot ?? [];
|
||||
$normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform);
|
||||
|
||||
if ($snapshot === []) {
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
$normalized['settings'] = array_values(array_filter(
|
||||
$normalized['settings'],
|
||||
fn (array $block) => strtolower((string) ($block['title'] ?? '')) !== 'general'
|
||||
));
|
||||
|
||||
$normalized['settings'][] = $this->buildUpdateSettingsBlock($snapshot);
|
||||
$normalized['settings'][] = $this->buildUserExperienceBlock($snapshot);
|
||||
$normalized['settings'][] = $this->buildAdvancedOptionsBlock($snapshot);
|
||||
|
||||
$normalized['settings'] = array_values(array_filter($normalized['settings']));
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
|
||||
{
|
||||
$snapshot = $snapshot ?? [];
|
||||
$normalized = $this->normalize($snapshot, $policyType, $platform);
|
||||
|
||||
return $this->defaultNormalizer->flattenNormalizedForDiff($normalized);
|
||||
}
|
||||
|
||||
private function buildUpdateSettingsBlock(array $snapshot): ?array
|
||||
{
|
||||
$keys = [
|
||||
'allowWindows11Upgrade',
|
||||
'automaticUpdateMode',
|
||||
'featureUpdatesDeferralPeriodInDays',
|
||||
'featureUpdatesPaused',
|
||||
'featureUpdatesPauseExpiryDateTime',
|
||||
'qualityUpdatesDeferralPeriodInDays',
|
||||
'qualityUpdatesPaused',
|
||||
'qualityUpdatesPauseExpiryDateTime',
|
||||
'updateWindowsDeviceDriverExclusion',
|
||||
];
|
||||
|
||||
return $this->buildBlock('Update Settings', $snapshot, $keys);
|
||||
}
|
||||
|
||||
private function buildUserExperienceBlock(array $snapshot): ?array
|
||||
{
|
||||
$keys = [
|
||||
'deadlineForFeatureUpdatesInDays',
|
||||
'deadlineForQualityUpdatesInDays',
|
||||
'deadlineGracePeriodInDays',
|
||||
'gracePeriodInDays',
|
||||
'restartActiveHoursStart',
|
||||
'restartActiveHoursEnd',
|
||||
'setActiveHours',
|
||||
'userPauseAccess',
|
||||
'userCheckAccess',
|
||||
];
|
||||
|
||||
return $this->buildBlock('User Experience', $snapshot, $keys);
|
||||
}
|
||||
|
||||
private function buildAdvancedOptionsBlock(array $snapshot): ?array
|
||||
{
|
||||
$keys = [
|
||||
'deliveryOptimizationMode',
|
||||
'prereleaseFeatures',
|
||||
'servicingChannel',
|
||||
'microsoftUpdateServiceAllowed',
|
||||
];
|
||||
|
||||
return $this->buildBlock('Advanced Options', $snapshot, $keys);
|
||||
}
|
||||
|
||||
private function buildBlock(string $title, array $snapshot, array $keys): ?array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
if (array_key_exists($key, $snapshot)) {
|
||||
$entries[] = [
|
||||
'key' => Str::headline($key),
|
||||
'value' => $this->formatValue($snapshot[$key]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($entries === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'keyValue',
|
||||
'title' => $title,
|
||||
'entries' => $entries,
|
||||
];
|
||||
}
|
||||
|
||||
private function formatValue(mixed $value): mixed
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'Yes' : 'No';
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return json_encode($value, JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@ -29,6 +29,14 @@ protected static function odataTypeMap(): array
|
||||
'windows' => '#microsoft.graph.windowsUpdateForBusinessConfiguration',
|
||||
'all' => '#microsoft.graph.windowsUpdateForBusinessConfiguration',
|
||||
],
|
||||
'windowsFeatureUpdateProfile' => [
|
||||
'windows' => '#microsoft.graph.windowsFeatureUpdateProfile',
|
||||
'all' => '#microsoft.graph.windowsFeatureUpdateProfile',
|
||||
],
|
||||
'windowsQualityUpdateProfile' => [
|
||||
'windows' => '#microsoft.graph.windowsQualityUpdateProfile',
|
||||
'all' => '#microsoft.graph.windowsQualityUpdateProfile',
|
||||
],
|
||||
'deviceCompliancePolicy' => [
|
||||
'windows' => '#microsoft.graph.windows10CompliancePolicy',
|
||||
'ios' => '#microsoft.graph.iosCompliancePolicy',
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -143,6 +143,13 @@
|
||||
'update_method' => 'PATCH',
|
||||
'id_field' => 'id',
|
||||
'hydration' => 'properties',
|
||||
'update_strip_keys' => [
|
||||
'version',
|
||||
'qualityUpdatesPauseStartDate',
|
||||
'featureUpdatesPauseStartDate',
|
||||
'qualityUpdatesWillBeRolledBack',
|
||||
'featureUpdatesWillBeRolledBack',
|
||||
],
|
||||
'assignments_list_path' => '/deviceManagement/deviceConfigurations/{id}/assignments',
|
||||
'assignments_create_path' => '/deviceManagement/deviceConfigurations/{id}/assign',
|
||||
'assignments_create_method' => 'POST',
|
||||
@ -153,6 +160,52 @@
|
||||
'supports_scope_tags' => true,
|
||||
'scope_tag_field' => 'roleScopeTagIds',
|
||||
],
|
||||
'windowsFeatureUpdateProfile' => [
|
||||
'resource' => 'deviceManagement/windowsFeatureUpdateProfiles',
|
||||
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime'],
|
||||
'allowed_expand' => [],
|
||||
'type_family' => [
|
||||
'#microsoft.graph.windowsFeatureUpdateProfile',
|
||||
],
|
||||
'create_method' => 'POST',
|
||||
'update_method' => 'PATCH',
|
||||
'id_field' => 'id',
|
||||
'hydration' => 'properties',
|
||||
'update_strip_keys' => [
|
||||
'deployableContentDisplayName',
|
||||
'endOfSupportDate',
|
||||
],
|
||||
'assignments_list_path' => '/deviceManagement/windowsFeatureUpdateProfiles/{id}/assignments',
|
||||
'assignments_create_path' => '/deviceManagement/windowsFeatureUpdateProfiles/{id}/assign',
|
||||
'assignments_create_method' => 'POST',
|
||||
'assignments_update_path' => '/deviceManagement/windowsFeatureUpdateProfiles/{id}/assignments/{assignmentId}',
|
||||
'assignments_update_method' => 'PATCH',
|
||||
'assignments_delete_path' => '/deviceManagement/windowsFeatureUpdateProfiles/{id}/assignments/{assignmentId}',
|
||||
'assignments_delete_method' => 'DELETE',
|
||||
],
|
||||
'windowsQualityUpdateProfile' => [
|
||||
'resource' => 'deviceManagement/windowsQualityUpdateProfiles',
|
||||
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime'],
|
||||
'allowed_expand' => [],
|
||||
'type_family' => [
|
||||
'#microsoft.graph.windowsQualityUpdateProfile',
|
||||
],
|
||||
'create_method' => 'POST',
|
||||
'update_method' => 'PATCH',
|
||||
'id_field' => 'id',
|
||||
'hydration' => 'properties',
|
||||
'update_strip_keys' => [
|
||||
'releaseDateDisplayName',
|
||||
'deployableContentDisplayName',
|
||||
],
|
||||
'assignments_list_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assignments',
|
||||
'assignments_create_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assign',
|
||||
'assignments_create_method' => 'POST',
|
||||
'assignments_update_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assignments/{assignmentId}',
|
||||
'assignments_update_method' => 'PATCH',
|
||||
'assignments_delete_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assignments/{assignmentId}',
|
||||
'assignments_delete_method' => 'DELETE',
|
||||
],
|
||||
'deviceCompliancePolicy' => [
|
||||
'resource' => 'deviceManagement/deviceCompliancePolicies',
|
||||
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'],
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
'category' => 'Configuration',
|
||||
'platform' => 'all',
|
||||
'endpoint' => 'deviceManagement/deviceConfigurations',
|
||||
'filter' => "@odata.type ne '#microsoft.graph.windowsUpdateForBusinessConfiguration'",
|
||||
'filter' => "not isof('microsoft.graph.windowsUpdateForBusinessConfiguration')",
|
||||
'backup' => 'full',
|
||||
'restore' => 'enabled',
|
||||
'risk' => 'medium',
|
||||
@ -39,11 +39,31 @@
|
||||
'category' => 'Update Management',
|
||||
'platform' => 'windows',
|
||||
'endpoint' => 'deviceManagement/deviceConfigurations',
|
||||
'filter' => "@odata.type eq '#microsoft.graph.windowsUpdateForBusinessConfiguration'",
|
||||
'filter' => "isof('microsoft.graph.windowsUpdateForBusinessConfiguration')",
|
||||
'backup' => 'full',
|
||||
'restore' => 'enabled',
|
||||
'risk' => 'medium-high',
|
||||
],
|
||||
[
|
||||
'type' => 'windowsFeatureUpdateProfile',
|
||||
'label' => 'Feature Updates (Windows)',
|
||||
'category' => 'Update Management',
|
||||
'platform' => 'windows',
|
||||
'endpoint' => 'deviceManagement/windowsFeatureUpdateProfiles',
|
||||
'backup' => 'full',
|
||||
'restore' => 'enabled',
|
||||
'risk' => 'high',
|
||||
],
|
||||
[
|
||||
'type' => 'windowsQualityUpdateProfile',
|
||||
'label' => 'Quality Updates (Windows)',
|
||||
'category' => 'Update Management',
|
||||
'platform' => 'windows',
|
||||
'endpoint' => 'deviceManagement/windowsQualityUpdateProfiles',
|
||||
'backup' => 'full',
|
||||
'restore' => 'enabled',
|
||||
'risk' => 'high',
|
||||
],
|
||||
[
|
||||
'type' => 'deviceCompliancePolicy',
|
||||
'label' => 'Device Compliance',
|
||||
@ -130,7 +150,7 @@
|
||||
'category' => 'Enrollment',
|
||||
'platform' => 'all',
|
||||
'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations',
|
||||
'filter' => "@odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'",
|
||||
'filter' => "isof('microsoft.graph.windows10EnrollmentCompletionPageConfiguration')",
|
||||
'backup' => 'full',
|
||||
'restore' => 'enabled',
|
||||
'risk' => 'medium',
|
||||
|
||||
@ -18,7 +18,9 @@
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<ini name="memory_limit" value="512M"/>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="INTUNE_TENANT_ID" value="" force="true"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
|
||||
@ -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>
|
||||
@ -7,37 +7,39 @@ ## Phase 0 — Specs (this PR)
|
||||
- [x] T001 Create `spec.md`, `plan.md`, `tasks.md` for Feature 011.
|
||||
|
||||
## Phase 1 — Data Model + Status Semantics
|
||||
- [ ] T002 Define RestoreRun lifecycle statuses and transitions (draft→scoped→checked→previewed→queued→running→completed|partial|failed).
|
||||
- [ ] T003 Add minimal persistence for wizard state (prefer JSON in `restore_runs.metadata` unless columns are required).
|
||||
- [ ] T004 Freeze `environment` + `highlander_label` at run creation for audit.
|
||||
- [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)
|
||||
- [ ] T005 Replace current single-form create with a 5-step wizard (Step 1–5 as in spec).
|
||||
- [ ] T006 Ensure changing `backup_set_id` resets downstream wizard state.
|
||||
- [ ] T007 Enforce “dry-run default ON” and keep execute disabled until all gates satisfied.
|
||||
- [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
|
||||
- [ ] T008 Implement scoped selection UI grouped by policy type + platform with search and bulk toggle.
|
||||
- [ ] T009 Mark preview-only types clearly and ensure they never execute.
|
||||
- [ ] T010 Ensure foundations are discoverable (assignment filters, scope tags, notification templates).
|
||||
- [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
|
||||
- [ ] T011 Implement `RestoreRiskChecker` (server-side) and persist `check_summary` + `check_results`.
|
||||
- [ ] T012 Render check results with severity (blocking/warning/safe) and block execute when blockers exist.
|
||||
- [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)
|
||||
- [ ] T013 Implement `RestoreDiffGenerator` using `PolicyNormalizer` + `VersionDiff`.
|
||||
- [ ] T014 Persist preview summary (and per-item diffs with safe limits) and require preview completion before execute.
|
||||
- [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
|
||||
- [ ] T015 Implement Step 5 confirmations (ack checkbox + tenant hard-confirm).
|
||||
- [ ] T016 Execute restore via a queued Job (preferred) and update statuses + timestamps.
|
||||
- [ ] T017 Persist execution outcomes and ensure audit logging entries exist for execution start/finish.
|
||||
- [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
|
||||
- [ ] T018 Add Pest tests for wizard gating rules and status transitions.
|
||||
- [ ] T019 Add Pest tests for safety checks persistence and blocking behavior.
|
||||
- [ ] T020 Add Pest tests for preview summary generation.
|
||||
- [ ] T021 Run `./vendor/bin/pint --dirty`.
|
||||
- [ ] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist).
|
||||
- [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.
|
||||
18
specs/012-windows-update-rings/plan.md
Normal file
18
specs/012-windows-update-rings/plan.md
Normal file
@ -0,0 +1,18 @@
|
||||
# Implementation Plan: Windows Update Rings (012)
|
||||
|
||||
**Branch**: `feat/012-windows-update-rings`
|
||||
**Date**: 2025-12-31
|
||||
**Spec Source**: [spec.md](./spec.md)
|
||||
|
||||
## Summary
|
||||
Make `windowsUpdateRing` snapshots/restores accurate by correctly capturing and applying its settings, and present a readable normalized view in Filament.
|
||||
|
||||
Also add coverage for Windows Feature Update Profiles (`windowsFeatureUpdateProfile`) and Windows Quality Update Profiles (`windowsQualityUpdateProfile`) so they can be synced, snapshotted, restored, and displayed in a readable normalized format.
|
||||
|
||||
## Execution Steps
|
||||
1. **Graph contract verification**: Ensure `config/graph_contracts.php` entry for `windowsUpdateRing` is correct and complete.
|
||||
2. **Snapshot capture hydration**: Extend `PolicySnapshotService` to correctly hydrate `windowsUpdateForBusinessConfiguration` settings into the policy payload.
|
||||
3. **Restore**: Extend `RestoreService` to apply `windowsUpdateRing` settings from a snapshot to the target policy in Intune.
|
||||
4. **UI normalization**: Add a dedicated normalizer for `windowsUpdateRing` that renders configured settings as readable rows in the Filament UI.
|
||||
5. **Feature/Quality Update Profiles**: Add Graph contract + supported types, and normalizers for `windowsFeatureUpdateProfile` and `windowsQualityUpdateProfile`.
|
||||
6. **Tests + formatting**: Add targeted Pest tests for sync filters/types, snapshot/normalized display (as applicable), and restore payload sanitization. Run `./vendor/bin/pint --dirty` and the affected tests.
|
||||
77
specs/012-windows-update-rings/spec.md
Normal file
77
specs/012-windows-update-rings/spec.md
Normal file
@ -0,0 +1,77 @@
|
||||
# Feature Specification: Windows Update Rings (012)
|
||||
|
||||
**Feature Branch**: `feat/012-windows-update-rings`
|
||||
**Created**: 2025-12-31
|
||||
**Status**: Draft
|
||||
**Input**: `config/graph_contracts.php` (windowsUpdateRing scope)
|
||||
|
||||
## Overview
|
||||
Add reliable coverage for **Windows Update Rings** (`windowsUpdateRing`) in the existing inventory/backup/version/restore flows.
|
||||
|
||||
This feature also extends coverage to **Windows Feature Update Profiles** ("Feature Updates"), which are managed under the `deviceManagement/windowsFeatureUpdateProfiles` endpoint and have `@odata.type` `#microsoft.graph.windowsFeatureUpdateProfile`.
|
||||
|
||||
This feature also extends coverage to **Windows Quality Update Profiles** ("Quality Updates"), which are managed under the `deviceManagement/windowsQualityUpdateProfiles` endpoint and have `@odata.type` `#microsoft.graph.windowsQualityUpdateProfile`.
|
||||
|
||||
This policy type is defined in `graph_contracts.php` and uses the `deviceManagement/deviceConfigurations` endpoint, identified by the `@odata.type` `#microsoft.graph.windowsUpdateForBusinessConfiguration`. This feature will focus on implementing the necessary UI normalization and ensuring the sync, backup, versioning, and restore flows function correctly for this policy type.
|
||||
|
||||
## In Scope
|
||||
- Policy type: `windowsUpdateRing`
|
||||
- Sync: Policies with `@odata.type` of `#microsoft.graph.windowsUpdateForBusinessConfiguration` should be correctly identified and synced as `windowsUpdateRing` policies.
|
||||
- Snapshot capture: Full snapshot of all settings within a Windows Update Ring policy.
|
||||
- Restore: Restore a Windows Update Ring policy from a snapshot.
|
||||
- UI: Display the settings of a Windows Update Ring policy in a readable, normalized format.
|
||||
|
||||
- Policy type: `windowsFeatureUpdateProfile`
|
||||
- Sync: Feature Update Profiles should be listed and synced from `deviceManagement/windowsFeatureUpdateProfiles`.
|
||||
- Snapshot capture: Full snapshot of the Feature Update Profile payload.
|
||||
- Restore: Restore a Feature Update Profile from a snapshot.
|
||||
- UI: Display the key settings of a Feature Update Profile in a readable, normalized format.
|
||||
|
||||
- Policy type: `windowsQualityUpdateProfile`
|
||||
- Sync: Quality Update Profiles should be listed and synced from `deviceManagement/windowsQualityUpdateProfiles`.
|
||||
- Snapshot capture: Full snapshot of the Quality Update Profile payload.
|
||||
- Restore: Restore a Quality Update Profile from a snapshot.
|
||||
- UI: Display the key settings of a Quality Update Profile in a readable, normalized format.
|
||||
|
||||
## Out of Scope (v1)
|
||||
- Advanced analytics or reporting on update compliance.
|
||||
- Per-setting partial restore.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 — Inventory + readable view
|
||||
As an admin, I can see my Windows Update Ring policies in the policy list and view their configured settings in a clear, understandable format.
|
||||
|
||||
**Acceptance**
|
||||
1. Windows Update Ring policies are listed in the main policy table with the correct type name.
|
||||
2. The policy detail view shows a structured list/table of configured settings (e.g., "Quality update deferral period", "Automatic update behavior").
|
||||
3. Policy Versions store the snapshot and render the settings in the “Normalized settings” view.
|
||||
|
||||
### User Story 2 — Backup/Version capture
|
||||
As an admin, when I back up or create a new version of a Windows Update Ring policy, the snapshot contains all its settings.
|
||||
|
||||
**Acceptance**
|
||||
1. The backup/version payload in the `snapshot` column contains all the properties of the `windowsUpdateForBusinessConfiguration` object.
|
||||
|
||||
### User Story 3 — Restore settings
|
||||
As an admin, I can restore a Windows Update Ring policy from a backup or a previous version.
|
||||
|
||||
**Acceptance**
|
||||
1. The restore operation correctly applies the settings from the snapshot to the target policy in Intune.
|
||||
2. The restore process is audited.
|
||||
|
||||
### User Story 4 — Feature Updates inventory + readable view
|
||||
As an admin, I can see my Windows Feature Update Profiles in the policy list and view their configured rollout/version settings in a clear, understandable format.
|
||||
|
||||
**Acceptance**
|
||||
1. Feature Update Profiles are listed in the main policy table with the correct type name.
|
||||
2. The policy detail view shows a structured list/table of configured settings (e.g., feature update version, rollout window).
|
||||
3. Policy Versions store the snapshot and render the settings in the “Normalized settings” view.
|
||||
|
||||
### User Story 5 — Quality Updates inventory + readable view
|
||||
As an admin, I can see my Windows Quality Update Profiles in the policy list and view their configured release/content settings in a clear, understandable format.
|
||||
|
||||
**Acceptance**
|
||||
1. Quality Update Profiles are listed in the main policy table with the correct type name.
|
||||
2. The policy detail view shows a structured list/table of configured settings (e.g., release, deployable content).
|
||||
3. Policy Versions store the snapshot and render the settings in the “Normalized settings” view.
|
||||
26
specs/012-windows-update-rings/tasks.md
Normal file
26
specs/012-windows-update-rings/tasks.md
Normal file
@ -0,0 +1,26 @@
|
||||
# Tasks: Windows Update Rings (012)
|
||||
|
||||
**Branch**: `feat/012-windows-update-rings` | **Date**: 2025-12-31
|
||||
**Input**: [spec.md](./spec.md), [plan.md](./plan.md)
|
||||
|
||||
## Phase 1: Contracts + Snapshot Hydration
|
||||
- [X] T001 Verify `config/graph_contracts.php` for `windowsUpdateRing` (resource, allowed_select, type_family, etc.).
|
||||
- [X] T002 Extend `PolicySnapshotService` to hydrate `windowsUpdateForBusinessConfiguration` settings.
|
||||
- [X] T001b Fix Graph filters for update rings (`isof(...)`) and add `windowsFeatureUpdateProfile` support.
|
||||
|
||||
## Phase 2: Restore
|
||||
- [X] T003 Implement restore apply for `windowsUpdateRing` settings in `RestoreService.php`.
|
||||
|
||||
## Phase 3: UI Normalization
|
||||
- [X] T004 Add `WindowsUpdateRingNormalizer` and register it (Policy “Normalized settings” is readable).
|
||||
- [X] T004b Add `WindowsFeatureUpdateProfileNormalizer` and register it (Policy “Normalized settings” is readable).
|
||||
- [X] T004c Add `WindowsQualityUpdateProfileNormalizer` and register it (Policy “Normalized settings” is readable).
|
||||
|
||||
## Phase 4: Tests + Verification
|
||||
- [X] T005 Add tests for sync filters + supported types.
|
||||
- [X] T006 Add tests for restore apply.
|
||||
- [X] T007 Run tests (targeted).
|
||||
- [X] T008 Run Pint (`./vendor/bin/pint --dirty`).
|
||||
|
||||
## Open TODOs (Follow-up)
|
||||
- None yet.
|
||||
@ -11,6 +11,7 @@
|
||||
|
||||
test('progress widget shows running operations for current tenant and user', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$tenant->makeCurrent();
|
||||
$user = User::factory()->create();
|
||||
|
||||
// Own running op
|
||||
@ -39,9 +40,6 @@
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
// $tenant->makeCurrent();
|
||||
$tenant->forceFill(['is_current' => true])->save();
|
||||
|
||||
auth()->login($user); // Login user explicitly for auth()->id() call in component
|
||||
|
||||
Livewire::actingAs($user)
|
||||
|
||||
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);
|
||||
});
|
||||
@ -12,7 +12,7 @@
|
||||
|
||||
test('policy detail shows app protection settings in readable sections', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
|
||||
@ -73,7 +73,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
});
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
@ -23,6 +23,8 @@
|
||||
'name' => 'Tenant',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Set 1',
|
||||
@ -60,6 +62,8 @@
|
||||
'name' => 'Tenant 2',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Set with restore',
|
||||
@ -93,6 +97,8 @@
|
||||
'name' => 'Tenant Force',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Set force',
|
||||
@ -132,6 +138,8 @@
|
||||
'name' => 'Tenant Restore Backup Set',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Set restore',
|
||||
@ -171,6 +179,8 @@
|
||||
'name' => 'Tenant Restore Run',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Set RR',
|
||||
@ -207,6 +217,8 @@
|
||||
'name' => 'Tenant Restore Restore Run',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Set for restore run restore',
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
test('malformed snapshot renders warning on policy and version detail', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
|
||||
@ -50,7 +50,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
||||
});
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
test('policies are listed for the active tenant', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
@ -12,13 +12,12 @@
|
||||
|
||||
test('policy detail shows normalized settings section', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
|
||||
@ -12,13 +12,12 @@
|
||||
|
||||
test('policy version detail renders tabs and scroll-safe blocks', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
|
||||
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');
|
||||
});
|
||||
@ -12,13 +12,12 @@
|
||||
|
||||
test('policy version view shows scope tags even when assignments are missing', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
|
||||
@ -12,13 +12,12 @@
|
||||
|
||||
test('policy version detail shows raw and normalized settings', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
|
||||
@ -11,11 +11,13 @@
|
||||
|
||||
test('policy versions render with timeline data', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-1',
|
||||
|
||||
@ -13,13 +13,12 @@
|
||||
|
||||
it('shows Settings tab for Settings Catalog policy', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Test Tenant',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
@ -86,13 +85,12 @@
|
||||
|
||||
it('shows display names instead of definition IDs', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Test Tenant',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
@ -143,13 +141,12 @@
|
||||
|
||||
it('shows fallback prettified labels when definitions not cached', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Test Tenant',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
@ -195,13 +192,12 @@
|
||||
|
||||
it('shows tabbed layout for non-Settings Catalog policies', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Test Tenant',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
@ -242,7 +238,7 @@
|
||||
// T034: Test display names shown (not definition IDs)
|
||||
it('displays setting display names instead of raw definition IDs', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Test Tenant',
|
||||
'is_current' => true,
|
||||
]);
|
||||
@ -296,7 +292,7 @@
|
||||
// T035: Test values formatted correctly
|
||||
it('formats setting values correctly based on type', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Test Tenant',
|
||||
'is_current' => true,
|
||||
]);
|
||||
@ -370,7 +366,7 @@
|
||||
// T036: Test search/filter functionality
|
||||
it('search filters settings in real-time', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Test Tenant',
|
||||
'is_current' => true,
|
||||
]);
|
||||
@ -433,7 +429,7 @@
|
||||
// T037: Test graceful degradation for missing definitions
|
||||
it('shows prettified fallback labels when definitions are not cached', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Test Tenant',
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
@ -11,7 +12,7 @@
|
||||
|
||||
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->makeCurrent();
|
||||
|
||||
@ -22,6 +23,13 @@
|
||||
'display_name' => 'Policy Display',
|
||||
'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([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-ignored',
|
||||
@ -32,10 +40,10 @@
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'item_count' => 2,
|
||||
'item_count' => 4,
|
||||
]);
|
||||
|
||||
BackupItem::factory()
|
||||
$policyItem = BackupItem::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->state([
|
||||
@ -47,7 +55,7 @@
|
||||
])
|
||||
->create();
|
||||
|
||||
BackupItem::factory()
|
||||
$ignoredPolicyItem = BackupItem::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->state([
|
||||
@ -59,7 +67,7 @@
|
||||
])
|
||||
->create();
|
||||
|
||||
BackupItem::factory()
|
||||
$scopeTagItem = BackupItem::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->state([
|
||||
@ -77,6 +85,18 @@
|
||||
])
|
||||
->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();
|
||||
$this->actingAs($user);
|
||||
|
||||
@ -84,13 +104,33 @@
|
||||
->fillForm([
|
||||
'backup_set_id' => $backupSet->id,
|
||||
])
|
||||
->assertSee('Policy Display')
|
||||
->assertDontSee('Ignored Policy')
|
||||
->assertSee('Scope Tag Alpha')
|
||||
->assertSee('Settings Catalog Policy')
|
||||
->assertSee('Scope Tag')
|
||||
->assertSee('restore: enabled')
|
||||
->assertSee('id: policy-1')
|
||||
->assertSee('id: tag-1')
|
||||
->goToNextWizardStep()
|
||||
->fillForm([
|
||||
'scope_mode' => 'selected',
|
||||
])
|
||||
->assertFormFieldVisible('backup_item_ids')
|
||||
->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');
|
||||
});
|
||||
|
||||
@ -13,13 +13,12 @@
|
||||
|
||||
test('settings catalog policies render a normalized settings table', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
|
||||
@ -75,16 +75,11 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
app()->instance(GraphClientInterface::class, new SettingsCatalogFakeGraphClient($responses));
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||
$_ENV['INTUNE_TENANT_ID'] = $tenant->tenant_id;
|
||||
$_SERVER['INTUNE_TENANT_ID'] = $tenant->tenant_id;
|
||||
|
||||
$tenant->makeCurrent();
|
||||
expect(Tenant::current()->id)->toBe($tenant->id);
|
||||
|
||||
|
||||
@ -13,13 +13,12 @@
|
||||
|
||||
test('settings catalog settings render as a filament table with details action', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
|
||||
213
tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php
Normal file
213
tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php
Normal file
@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
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\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
class WindowsUpdateProfilesRestoreGraphClient implements GraphClientInterface
|
||||
{
|
||||
/**
|
||||
* @var array<int, array{policyType:string,policyId:string,payload:array,options:array}>
|
||||
*/
|
||||
public array $applyPolicyCalls = [];
|
||||
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, ['payload' => []]);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
$this->applyPolicyCalls[] = [
|
||||
'policyType' => $policyType,
|
||||
'policyId' => $policyId,
|
||||
'payload' => $payload,
|
||||
'options' => $options,
|
||||
];
|
||||
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
}
|
||||
|
||||
test('restore execution applies windows feature update profile with sanitized payload', function () {
|
||||
$client = new WindowsUpdateProfilesRestoreGraphClient;
|
||||
app()->instance(GraphClientInterface::class, $client);
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => 'tenant-1',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
$policy = Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-feature',
|
||||
'policy_type' => 'windowsFeatureUpdateProfile',
|
||||
'display_name' => 'Feature Updates A',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupPayload = [
|
||||
'id' => 'policy-feature',
|
||||
'@odata.type' => '#microsoft.graph.windowsFeatureUpdateProfile',
|
||||
'displayName' => 'Feature Updates A',
|
||||
'featureUpdateVersion' => 'Windows 11, version 23H2',
|
||||
'deployableContentDisplayName' => 'Some Content',
|
||||
'endOfSupportDate' => '2026-01-01T00:00:00Z',
|
||||
'roleScopeTagIds' => ['0'],
|
||||
];
|
||||
|
||||
$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' => $backupPayload,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create(['email' => 'tester@example.com']);
|
||||
$this->actingAs($user);
|
||||
|
||||
$service = app(RestoreService::class);
|
||||
$run = $service->execute(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: [$backupItem->id],
|
||||
dryRun: false,
|
||||
actorEmail: $user->email,
|
||||
actorName: $user->name,
|
||||
);
|
||||
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->results[0]['status'])->toBe('applied');
|
||||
|
||||
expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1);
|
||||
|
||||
expect($client->applyPolicyCalls)->toHaveCount(1);
|
||||
expect($client->applyPolicyCalls[0]['policyType'])->toBe('windowsFeatureUpdateProfile');
|
||||
expect($client->applyPolicyCalls[0]['policyId'])->toBe('policy-feature');
|
||||
expect($client->applyPolicyCalls[0]['options']['method'] ?? null)->toBe('PATCH');
|
||||
|
||||
expect($client->applyPolicyCalls[0]['payload']['featureUpdateVersion'])->toBe('Windows 11, version 23H2');
|
||||
expect($client->applyPolicyCalls[0]['payload']['roleScopeTagIds'])->toBe(['0']);
|
||||
|
||||
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('id');
|
||||
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('@odata.type');
|
||||
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('deployableContentDisplayName');
|
||||
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('endOfSupportDate');
|
||||
});
|
||||
|
||||
test('restore execution applies windows quality update profile with sanitized payload', function () {
|
||||
$client = new WindowsUpdateProfilesRestoreGraphClient;
|
||||
app()->instance(GraphClientInterface::class, $client);
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => 'tenant-1',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
$policy = Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-quality',
|
||||
'policy_type' => 'windowsQualityUpdateProfile',
|
||||
'display_name' => 'Quality Updates A',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupPayload = [
|
||||
'id' => 'policy-quality',
|
||||
'@odata.type' => '#microsoft.graph.windowsQualityUpdateProfile',
|
||||
'displayName' => 'Quality Updates A',
|
||||
'qualityUpdateCveIds' => ['CVE-2025-0001'],
|
||||
'deployableContentDisplayName' => 'Some Content',
|
||||
'releaseDateDisplayName' => 'January 2026',
|
||||
'roleScopeTagIds' => ['0'],
|
||||
];
|
||||
|
||||
$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' => $backupPayload,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create(['email' => 'tester@example.com']);
|
||||
$this->actingAs($user);
|
||||
|
||||
$service = app(RestoreService::class);
|
||||
$run = $service->execute(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: [$backupItem->id],
|
||||
dryRun: false,
|
||||
actorEmail: $user->email,
|
||||
actorName: $user->name,
|
||||
);
|
||||
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->results[0]['status'])->toBe('applied');
|
||||
|
||||
expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1);
|
||||
|
||||
expect($client->applyPolicyCalls)->toHaveCount(1);
|
||||
expect($client->applyPolicyCalls[0]['policyType'])->toBe('windowsQualityUpdateProfile');
|
||||
expect($client->applyPolicyCalls[0]['policyId'])->toBe('policy-quality');
|
||||
expect($client->applyPolicyCalls[0]['options']['method'] ?? null)->toBe('PATCH');
|
||||
|
||||
expect($client->applyPolicyCalls[0]['payload']['qualityUpdateCveIds'])->toBe(['CVE-2025-0001']);
|
||||
expect($client->applyPolicyCalls[0]['payload']['roleScopeTagIds'])->toBe(['0']);
|
||||
|
||||
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('id');
|
||||
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('@odata.type');
|
||||
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('deployableContentDisplayName');
|
||||
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('releaseDateDisplayName');
|
||||
});
|
||||
77
tests/Feature/Filament/WindowsUpdateRingPolicyTest.php
Normal file
77
tests/Feature/Filament/WindowsUpdateRingPolicyTest.php
Normal file
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('policy detail shows normalized settings for windows update ring', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-wuring',
|
||||
'policy_type' => 'windowsUpdateRing',
|
||||
'display_name' => 'Windows Update Ring A',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
PolicyVersion::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'policy_id' => $policy->id,
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => CarbonImmutable::now(),
|
||||
'snapshot' => [
|
||||
'@odata.type' => '#microsoft.graph.windowsUpdateForBusinessConfiguration',
|
||||
'automaticUpdateMode' => 'autoInstallAtMaintenanceTime',
|
||||
'featureUpdatesDeferralPeriodInDays' => 14,
|
||||
'deadlineForFeatureUpdatesInDays' => 7,
|
||||
'deliveryOptimizationMode' => 'httpWithPeeringNat',
|
||||
'qualityUpdatesPaused' => false,
|
||||
'userPauseAccess' => 'allow',
|
||||
],
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(PolicyResource::getUrl('view', ['record' => $policy]));
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
// Check for correct titles and settings from the normalizer
|
||||
$response->assertSee('Update Settings');
|
||||
$response->assertSee('Automatic Update Mode');
|
||||
$response->assertSee('autoInstallAtMaintenanceTime');
|
||||
$response->assertSee('Feature Updates Deferral Period In Days');
|
||||
$response->assertSee('14');
|
||||
$response->assertSee('Quality Updates Paused');
|
||||
$response->assertSee('No');
|
||||
|
||||
$response->assertSee('User Experience');
|
||||
$response->assertSee('Deadline For Feature Updates In Days');
|
||||
$response->assertSee('7');
|
||||
$response->assertSee('User Pause Access');
|
||||
$response->assertSee('allow');
|
||||
|
||||
$response->assertSee('Advanced Options');
|
||||
$response->assertSee('Delivery Optimization Mode');
|
||||
$response->assertSee('httpWithPeeringNat');
|
||||
|
||||
// $response->assertDontSee('@odata.type');
|
||||
});
|
||||
151
tests/Feature/Filament/WindowsUpdateRingRestoreTest.php
Normal file
151
tests/Feature/Filament/WindowsUpdateRingRestoreTest.php
Normal file
@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
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\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('restore execution applies windows update ring and records audit log', function () {
|
||||
$client = new class implements GraphClientInterface
|
||||
{
|
||||
public array $applied = [];
|
||||
|
||||
public array $requests = [];
|
||||
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, ['payload' => []]);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
$this->applied[] = [
|
||||
'policyType' => $policyType,
|
||||
'policyId' => $policyId,
|
||||
'payload' => $payload,
|
||||
'options' => $options,
|
||||
];
|
||||
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
$this->requests[] = [
|
||||
'method' => strtoupper($method),
|
||||
'path' => $path,
|
||||
'payload' => $options['json'] ?? null,
|
||||
];
|
||||
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
};
|
||||
|
||||
app()->instance(GraphClientInterface::class, $client);
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => 'tenant-1',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
$policy = Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-wuring',
|
||||
'policy_type' => 'windowsUpdateRing',
|
||||
'display_name' => 'Windows Update Ring A',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupPayload = [
|
||||
'id' => 'policy-wuring',
|
||||
'@odata.type' => '#microsoft.graph.windowsUpdateForBusinessConfiguration',
|
||||
'automaticUpdateMode' => 'autoInstallAtMaintenanceTime',
|
||||
'featureUpdatesDeferralPeriodInDays' => 14,
|
||||
'version' => 7,
|
||||
'qualityUpdatesPauseStartDate' => '2025-01-01T00:00:00Z',
|
||||
'featureUpdatesPauseStartDate' => '2025-01-02T00:00:00Z',
|
||||
'qualityUpdatesWillBeRolledBack' => false,
|
||||
'featureUpdatesWillBeRolledBack' => false,
|
||||
'roleScopeTagIds' => ['0'],
|
||||
];
|
||||
|
||||
$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' => $backupPayload,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create(['email' => 'tester@example.com']);
|
||||
$this->actingAs($user);
|
||||
|
||||
$service = app(RestoreService::class);
|
||||
$run = $service->execute(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: [$backupItem->id],
|
||||
dryRun: false,
|
||||
actorEmail: $user->email,
|
||||
actorName: $user->name,
|
||||
);
|
||||
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->results[0]['status'])->toBe('applied');
|
||||
|
||||
$this->assertDatabaseHas('audit_logs', [
|
||||
'action' => 'restore.executed',
|
||||
'resource_id' => (string) $run->id,
|
||||
]);
|
||||
|
||||
expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1);
|
||||
|
||||
expect($client->requests)->toHaveCount(1);
|
||||
expect($client->requests[0]['method'])->toBe('PATCH');
|
||||
expect($client->requests[0]['path'])->toBe('deviceManagement/deviceConfigurations/policy-wuring/microsoft.graph.windowsUpdateForBusinessConfiguration');
|
||||
|
||||
expect($client->requests[0]['payload']['automaticUpdateMode'])->toBe('autoInstallAtMaintenanceTime');
|
||||
expect($client->requests[0]['payload']['featureUpdatesDeferralPeriodInDays'])->toBe(14);
|
||||
expect($client->requests[0]['payload']['roleScopeTagIds'])->toBe(['0']);
|
||||
|
||||
expect($client->requests[0]['payload'])->not->toHaveKey('id');
|
||||
expect($client->requests[0]['payload'])->not->toHaveKey('@odata.type');
|
||||
expect($client->requests[0]['payload'])->not->toHaveKey('version');
|
||||
expect($client->requests[0]['payload'])->not->toHaveKey('qualityUpdatesPauseStartDate');
|
||||
expect($client->requests[0]['payload'])->not->toHaveKey('featureUpdatesPauseStartDate');
|
||||
expect($client->requests[0]['payload'])->not->toHaveKey('qualityUpdatesWillBeRolledBack');
|
||||
expect($client->requests[0]['payload'])->not->toHaveKey('featureUpdatesWillBeRolledBack');
|
||||
});
|
||||
@ -49,7 +49,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
|
||||
test('sync skips managed app configurations from app protection inventory', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant'),
|
||||
'tenant_id' => 'test-tenant',
|
||||
'name' => 'Test Tenant',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
|
||||
@ -49,7 +49,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
|
||||
test('sync revives ignored policies when they exist in Intune', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant'),
|
||||
'tenant_id' => 'test-tenant',
|
||||
'name' => 'Test Tenant',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
@ -94,7 +94,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
|
||||
test('sync creates new policies even if ignored ones exist with same external_id', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant-2'),
|
||||
'tenant_id' => 'test-tenant-2',
|
||||
'name' => 'Test Tenant 2',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
|
||||
77
tests/Feature/PolicySyncServiceTest.php
Normal file
77
tests/Feature/PolicySyncServiceTest.php
Normal file
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphLogger;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Intune\PolicySyncService;
|
||||
|
||||
use function Pest\Laravel\mock;
|
||||
|
||||
it('marks targeted managed app configurations as ignored during sync', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-1',
|
||||
'policy_type' => 'appProtectionPolicy',
|
||||
'ignored_at' => null,
|
||||
]);
|
||||
|
||||
$logger = mock(GraphLogger::class);
|
||||
|
||||
$logger->shouldReceive('logRequest')
|
||||
->zeroOrMoreTimes()
|
||||
->andReturnNull();
|
||||
|
||||
$logger->shouldReceive('logResponse')
|
||||
->zeroOrMoreTimes()
|
||||
->andReturnNull();
|
||||
|
||||
mock(GraphClientInterface::class)
|
||||
->shouldReceive('listPolicies')
|
||||
->once()
|
||||
->andReturn(new GraphResponse(
|
||||
success: true,
|
||||
data: [
|
||||
[
|
||||
'id' => 'policy-1',
|
||||
'displayName' => 'Ignored policy',
|
||||
'@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration',
|
||||
],
|
||||
],
|
||||
));
|
||||
|
||||
$service = app(PolicySyncService::class);
|
||||
|
||||
$synced = $service->syncPolicies($tenant, [
|
||||
['type' => 'appProtectionPolicy'],
|
||||
]);
|
||||
|
||||
$policy->refresh();
|
||||
|
||||
expect($policy->ignored_at)->not->toBeNull();
|
||||
expect($synced)->toBeArray()->toBeEmpty();
|
||||
});
|
||||
|
||||
it('uses isof filters for windows update rings and supports feature/quality update profiles', function () {
|
||||
$supported = config('tenantpilot.supported_policy_types');
|
||||
$byType = collect($supported)->keyBy('type');
|
||||
|
||||
expect($byType)->toHaveKeys(['deviceConfiguration', 'windowsUpdateRing', 'windowsFeatureUpdateProfile', 'windowsQualityUpdateProfile']);
|
||||
|
||||
expect($byType['deviceConfiguration']['filter'] ?? null)
|
||||
->toBe("not isof('microsoft.graph.windowsUpdateForBusinessConfiguration')");
|
||||
|
||||
expect($byType['windowsUpdateRing']['filter'] ?? null)
|
||||
->toBe("isof('microsoft.graph.windowsUpdateForBusinessConfiguration')");
|
||||
|
||||
expect($byType['windowsFeatureUpdateProfile']['endpoint'] ?? null)
|
||||
->toBe('deviceManagement/windowsFeatureUpdateProfiles');
|
||||
|
||||
expect($byType['windowsQualityUpdateProfile']['endpoint'] ?? null)
|
||||
->toBe('deviceManagement/windowsQualityUpdateProfiles');
|
||||
});
|
||||
@ -77,12 +77,23 @@
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(CreateRestoreRun::class)
|
||||
$component = Livewire::test(CreateRestoreRun::class)
|
||||
->fillForm([
|
||||
'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 () {
|
||||
@ -150,12 +161,19 @@
|
||||
Livewire::test(CreateRestoreRun::class)
|
||||
->fillForm([
|
||||
'backup_set_id' => $backupSet->id,
|
||||
])
|
||||
->goToNextWizardStep()
|
||||
->fillForm([
|
||||
'scope_mode' => 'selected',
|
||||
'backup_item_ids' => [$backupItem->id],
|
||||
'group_mapping' => [
|
||||
'source-group-1' => 'target-group-1',
|
||||
],
|
||||
'is_dry_run' => true,
|
||||
])
|
||||
->goToNextWizardStep()
|
||||
->goToNextWizardStep()
|
||||
->callFormComponentAction('preview_diffs', 'run_restore_preview')
|
||||
->goToNextWizardStep()
|
||||
->call('create')
|
||||
->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');
|
||||
});
|
||||
@ -4,4 +4,13 @@
|
||||
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase {}
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
putenv('INTUNE_TENANT_ID');
|
||||
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
|
||||
}
|
||||
}
|
||||
|
||||
@ -115,7 +115,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
expect($result['items'][1]['source_id'])->toBe('filter-2');
|
||||
|
||||
expect($client->requests[0]['path'])->toBe('deviceManagement/assignmentFilters');
|
||||
expect($client->requests[0]['options']['query']['$select'])->toBe(['id', 'displayName']);
|
||||
expect($client->requests[0]['options']['query']['$select'])->toBe('id,displayName');
|
||||
expect($client->requests[1]['path'])->toBe('deviceManagement/assignmentFilters?$skiptoken=abc');
|
||||
expect($client->requests[1]['options']['query'])->toBe([]);
|
||||
});
|
||||
|
||||
@ -169,3 +169,85 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
expect($client->requests[0][3]['select'])->toContain('roleScopeTagIds');
|
||||
expect($client->requests[0][3]['select'])->not->toContain('@odata.type');
|
||||
});
|
||||
|
||||
class WindowsUpdateRingSnapshotGraphClient implements GraphClientInterface
|
||||
{
|
||||
public array $requests = [];
|
||||
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(success: true, data: []);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
$this->requests[] = ['getPolicy', $policyType, $policyId, $options];
|
||||
|
||||
return new GraphResponse(success: true, data: [
|
||||
'payload' => [
|
||||
'id' => $policyId,
|
||||
'@odata.type' => '#microsoft.graph.windowsUpdateForBusinessConfiguration',
|
||||
'displayName' => 'Ring A',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(success: true, data: []);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(success: true, data: []);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(success: true, data: []);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
$this->requests[] = [$method, $path];
|
||||
|
||||
if ($method === 'GET' && $path === 'deviceManagement/deviceConfigurations/policy-wuring/microsoft.graph.windowsUpdateForBusinessConfiguration') {
|
||||
return new GraphResponse(success: true, data: [
|
||||
'automaticUpdateMode' => 'autoInstallAtMaintenanceTime',
|
||||
'featureUpdatesDeferralPeriodInDays' => 14,
|
||||
]);
|
||||
}
|
||||
|
||||
return new GraphResponse(success: true, data: []);
|
||||
}
|
||||
}
|
||||
|
||||
it('hydrates windows update ring snapshots via derived type cast endpoint', function () {
|
||||
$client = new WindowsUpdateRingSnapshotGraphClient;
|
||||
app()->instance(GraphClientInterface::class, $client);
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-wuring',
|
||||
'app_client_id' => 'client-123',
|
||||
'app_client_secret' => 'secret-123',
|
||||
'is_current' => true,
|
||||
]);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-wuring',
|
||||
'policy_type' => 'windowsUpdateRing',
|
||||
'display_name' => 'Ring A',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$service = app(PolicySnapshotService::class);
|
||||
$result = $service->fetch($tenant, $policy);
|
||||
|
||||
expect($result)->toHaveKey('payload');
|
||||
expect($result['payload']['@odata.type'])->toBe('#microsoft.graph.windowsUpdateForBusinessConfiguration');
|
||||
expect($result['payload']['automaticUpdateMode'])->toBe('autoInstallAtMaintenanceTime');
|
||||
expect($result['payload']['featureUpdatesDeferralPeriodInDays'])->toBe(14);
|
||||
expect($result['metadata']['properties_hydration'] ?? null)->toBe('complete');
|
||||
});
|
||||
|
||||
@ -108,3 +108,27 @@ function restoreIntuneTenantId(string|false $original): void
|
||||
|
||||
restoreIntuneTenantId($originalEnv);
|
||||
});
|
||||
|
||||
it('makeCurrent keeps tenant current when already current', function () {
|
||||
$originalEnv = getenv('INTUNE_TENANT_ID');
|
||||
putenv('INTUNE_TENANT_ID=');
|
||||
|
||||
$current = Tenant::create([
|
||||
'tenant_id' => 'tenant-current',
|
||||
'name' => 'Already Current',
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
$other = Tenant::create([
|
||||
'tenant_id' => 'tenant-other',
|
||||
'name' => 'Other Tenant',
|
||||
'is_current' => false,
|
||||
]);
|
||||
|
||||
$current->makeCurrent();
|
||||
|
||||
expect($current->fresh()->is_current)->toBeTrue();
|
||||
expect($other->fresh()->is_current)->toBeFalse();
|
||||
|
||||
restoreIntuneTenantId($originalEnv);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user