feat/012-windows-update-rings #18

Merged
ahmido merged 24 commits from feat/012-windows-update-rings into dev 2026-01-01 10:44:18 +00:00
59 changed files with 4752 additions and 140 deletions

View File

@ -6,6 +6,8 @@
use App\Jobs\BulkPolicyVersionForceDeleteJob; use App\Jobs\BulkPolicyVersionForceDeleteJob;
use App\Jobs\BulkPolicyVersionPruneJob; use App\Jobs\BulkPolicyVersionPruneJob;
use App\Jobs\BulkPolicyVersionRestoreJob; use App\Jobs\BulkPolicyVersionRestoreJob;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\BulkOperationService; use App\Services\BulkOperationService;
@ -13,6 +15,7 @@
use App\Services\Intune\PolicyNormalizer; use App\Services\Intune\PolicyNormalizer;
use App\Services\Intune\VersionDiff; use App\Services\Intune\VersionDiff;
use BackedEnum; use BackedEnum;
use Carbon\CarbonImmutable;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\BulkAction; use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
@ -183,6 +186,96 @@ public static function table(Table $table): Table
->url(fn (PolicyVersion $record) => static::getUrl('view', ['record' => $record])) ->url(fn (PolicyVersion $record) => static::getUrl('view', ['record' => $record]))
->openUrlInNewTab(false), ->openUrlInNewTab(false),
Actions\ActionGroup::make([ Actions\ActionGroup::make([
Actions\Action::make('restore_via_wizard')
->label('Restore via Wizard')
->icon('heroicon-o-arrow-path-rounded-square')
->color('primary')
->requiresConfirmation()
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')
->action(function (PolicyVersion $record) {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant || $record->tenant_id !== $tenant->id) {
Notification::make()
->title('Policy version belongs to a different tenant')
->danger()
->send();
return;
}
$policy = $record->policy;
if (! $policy) {
Notification::make()
->title('Policy could not be found for this version')
->danger()
->send();
return;
}
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => sprintf(
'Policy Version Restore • %s • v%d',
$policy->display_name,
$record->version_number
),
'created_by' => $user?->email,
'status' => 'completed',
'item_count' => 1,
'completed_at' => CarbonImmutable::now(),
'metadata' => [
'source' => 'policy_version',
'policy_version_id' => $record->id,
'policy_version_number' => $record->version_number,
'policy_id' => $policy->id,
],
]);
$scopeTags = is_array($record->scope_tags) ? $record->scope_tags : [];
$scopeTagIds = $scopeTags['ids'] ?? null;
$scopeTagNames = $scopeTags['names'] ?? null;
$backupItemMetadata = [
'source' => 'policy_version',
'display_name' => $policy->display_name,
'policy_version_id' => $record->id,
'policy_version_number' => $record->version_number,
'version_captured_at' => $record->captured_at?->toIso8601String(),
];
if (is_array($scopeTagIds) && $scopeTagIds !== []) {
$backupItemMetadata['scope_tag_ids'] = $scopeTagIds;
}
if (is_array($scopeTagNames) && $scopeTagNames !== []) {
$backupItemMetadata['scope_tag_names'] = $scopeTagNames;
}
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_version_id' => $record->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'captured_at' => $record->captured_at ?? CarbonImmutable::now(),
'payload' => $record->snapshot ?? [],
'metadata' => $backupItemMetadata,
'assignments' => $record->assignments,
]);
return redirect()->to(RestoreRunResource::getUrl('create', [
'backup_set_id' => $backupSet->id,
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id],
]));
}),
Actions\Action::make('archive') Actions\Action::make('archive')
->label('Archive') ->label('Archive')
->color('danger') ->color('danger')

View File

@ -6,6 +6,7 @@
use App\Jobs\BulkRestoreRunDeleteJob; use App\Jobs\BulkRestoreRunDeleteJob;
use App\Jobs\BulkRestoreRunForceDeleteJob; use App\Jobs\BulkRestoreRunForceDeleteJob;
use App\Jobs\BulkRestoreRunRestoreJob; use App\Jobs\BulkRestoreRunRestoreJob;
use App\Jobs\ExecuteRestoreRunJob;
use App\Models\BackupItem; use App\Models\BackupItem;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\RestoreRun; use App\Models\RestoreRun;
@ -13,7 +14,11 @@
use App\Services\BulkOperationService; use App\Services\BulkOperationService;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GroupResolver; use App\Services\Graph\GroupResolver;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RestoreDiffGenerator;
use App\Services\Intune\RestoreRiskChecker;
use App\Services\Intune\RestoreService; use App\Services\Intune\RestoreService;
use App\Support\RestoreRunStatus;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
@ -26,13 +31,16 @@
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Components\Wizard\Step;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Contracts\HasTable; use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use UnitEnum; use UnitEnum;
class RestoreRunResource extends Resource class RestoreRunResource extends Resource
@ -69,8 +77,10 @@ public static function form(Schema $schema): Schema
}) })
->reactive() ->reactive()
->afterStateUpdated(function (Set $set): void { ->afterStateUpdated(function (Set $set): void {
$set('backup_item_ids', []); $set('scope_mode', 'all');
$set('backup_item_ids', null);
$set('group_mapping', []); $set('group_mapping', []);
$set('is_dry_run', true);
}) })
->required(), ->required(),
Forms\Components\CheckboxList::make('backup_item_ids') Forms\Components\CheckboxList::make('backup_item_ids')
@ -137,6 +147,491 @@ public static function form(Schema $schema): Schema
]); ]);
} }
/**
* @return array<int, Step>
*/
public static function getWizardSteps(): array
{
return [
Step::make('Select Backup Set')
->description('What are we restoring from?')
->schema([
Forms\Components\Select::make('backup_set_id')
->label('Backup set')
->options(function () {
$tenantId = Tenant::current()->getKey();
return BackupSet::query()
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
->orderByDesc('created_at')
->get()
->mapWithKeys(function (BackupSet $set) {
$label = sprintf(
'%s • %s items • %s',
$set->name,
$set->item_count ?? 0,
optional($set->created_at)->format('Y-m-d H:i')
);
return [$set->id => $label];
});
})
->reactive()
->afterStateUpdated(function (Set $set, Get $get): void {
$set('scope_mode', 'all');
$set('backup_item_ids', null);
$set('group_mapping', static::groupMappingPlaceholders(
backupSetId: $get('backup_set_id'),
scopeMode: 'all',
selectedItemIds: null,
tenant: Tenant::current(),
));
$set('is_dry_run', true);
$set('acknowledged_impact', false);
$set('tenant_confirm', null);
$set('check_summary', null);
$set('check_results', []);
$set('checks_ran_at', null);
$set('preview_summary', null);
$set('preview_diffs', []);
$set('preview_ran_at', null);
})
->required(),
]),
Step::make('Define Restore Scope')
->description('What exactly should be restored?')
->schema([
Forms\Components\Radio::make('scope_mode')
->label('Scope')
->options([
'all' => 'All items (default)',
'selected' => 'Selected items only',
])
->default('all')
->reactive()
->afterStateUpdated(function (Set $set, Get $get, $state): void {
$backupSetId = $get('backup_set_id');
$tenant = Tenant::current();
$set('is_dry_run', true);
$set('acknowledged_impact', false);
$set('tenant_confirm', null);
$set('check_summary', null);
$set('check_results', []);
$set('checks_ran_at', null);
$set('preview_summary', null);
$set('preview_diffs', []);
$set('preview_ran_at', null);
if ($state === 'all') {
$set('backup_item_ids', null);
$set('group_mapping', static::groupMappingPlaceholders(
backupSetId: $backupSetId,
scopeMode: 'all',
selectedItemIds: null,
tenant: $tenant,
));
return;
}
$set('group_mapping', []);
$set('backup_item_ids', []);
})
->required(),
Forms\Components\Select::make('backup_item_ids')
->label('Items to restore')
->multiple()
->searchable()
->searchValues()
->searchDebounce(400)
->optionsLimit(300)
->options(fn (Get $get) => static::restoreItemGroupedOptions($get('backup_set_id')))
->reactive()
->afterStateUpdated(function (Set $set, Get $get): void {
$backupSetId = $get('backup_set_id');
$selectedItemIds = $get('backup_item_ids');
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
$tenant = Tenant::current();
$set('group_mapping', static::groupMappingPlaceholders(
backupSetId: $backupSetId,
scopeMode: 'selected',
selectedItemIds: $selectedItemIds,
tenant: $tenant,
));
$set('is_dry_run', true);
$set('acknowledged_impact', false);
$set('tenant_confirm', null);
$set('check_summary', null);
$set('check_results', []);
$set('checks_ran_at', null);
$set('preview_summary', null);
$set('preview_diffs', []);
$set('preview_ran_at', null);
})
->visible(fn (Get $get): bool => $get('scope_mode') === 'selected')
->required(fn (Get $get): bool => $get('scope_mode') === 'selected')
->hintActions([
Actions\Action::make('select_all_backup_items')
->label('Select all')
->icon('heroicon-o-check')
->color('gray')
->visible(fn (Get $get): bool => filled($get('backup_set_id')) && $get('scope_mode') === 'selected')
->action(function (Get $get, Set $set): void {
$groupedOptions = static::restoreItemGroupedOptions($get('backup_set_id'));
$allItemIds = [];
foreach ($groupedOptions as $options) {
$allItemIds = array_merge($allItemIds, array_keys($options));
}
$set('backup_item_ids', array_values($allItemIds), shouldCallUpdatedHooks: true);
}),
Actions\Action::make('clear_backup_items')
->label('Clear')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (Get $get): bool => $get('scope_mode') === 'selected')
->action(fn (Set $set) => $set('backup_item_ids', [], shouldCallUpdatedHooks: true)),
])
->helperText('Search by name or ID. Include foundations (scope tags, assignment filters) with policies to re-map IDs. Options are grouped by category, type, and platform.'),
Section::make('Group mapping')
->description('Some source groups do not exist in the target tenant. Map them or choose Skip.')
->schema(function (Get $get): array {
$backupSetId = $get('backup_set_id');
$scopeMode = $get('scope_mode') ?? 'all';
$selectedItemIds = $get('backup_item_ids');
$tenant = Tenant::current();
if (! $tenant || ! $backupSetId) {
return [];
}
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
if ($scopeMode === 'selected' && ($selectedItemIds === null || $selectedItemIds === [])) {
return [];
}
$unresolved = static::unresolvedGroups(
backupSetId: $backupSetId,
selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null,
tenant: $tenant
);
return array_map(function (array $group) use ($tenant): Forms\Components\Select {
$groupId = $group['id'];
$label = $group['label'];
return Forms\Components\Select::make("group_mapping.{$groupId}")
->label($label)
->options([
'SKIP' => 'Skip assignment',
])
->searchable()
->getSearchResultsUsing(fn (string $search) => static::targetGroupOptions($tenant, $search))
->getOptionLabelUsing(fn (?string $value) => static::resolveTargetGroupLabel($tenant, $value))
->reactive()
->afterStateUpdated(function (Set $set): void {
$set('check_summary', null);
$set('check_results', []);
$set('checks_ran_at', null);
$set('preview_summary', null);
$set('preview_diffs', []);
$set('preview_ran_at', null);
})
->helperText('Choose a target group or select Skip.');
}, $unresolved);
})
->visible(function (Get $get): bool {
$backupSetId = $get('backup_set_id');
$scopeMode = $get('scope_mode') ?? 'all';
$selectedItemIds = $get('backup_item_ids');
$tenant = Tenant::current();
if (! $tenant || ! $backupSetId) {
return false;
}
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
if ($scopeMode === 'selected' && ($selectedItemIds === null || $selectedItemIds === [])) {
return false;
}
return static::unresolvedGroups(
backupSetId: $backupSetId,
selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null,
tenant: $tenant
) !== [];
}),
]),
Step::make('Safety & Conflict Checks')
->description('Is this dangerous?')
->schema([
Forms\Components\Hidden::make('check_summary')
->default(null),
Forms\Components\Hidden::make('checks_ran_at')
->default(null),
Forms\Components\ViewField::make('check_results')
->label('Checks')
->default([])
->view('filament.forms.components.restore-run-checks')
->viewData(fn (Get $get): array => [
'summary' => $get('check_summary'),
'ranAt' => $get('checks_ran_at'),
])
->hintActions([
Actions\Action::make('run_restore_checks')
->label('Run checks')
->icon('heroicon-o-shield-check')
->color('gray')
->visible(fn (Get $get): bool => filled($get('backup_set_id')))
->action(function (Get $get, Set $set): void {
$tenant = Tenant::current();
if (! $tenant) {
return;
}
$backupSetId = $get('backup_set_id');
if (! $backupSetId) {
return;
}
$backupSet = BackupSet::find($backupSetId);
if (! $backupSet || $backupSet->tenant_id !== $tenant->id) {
Notification::make()
->title('Unable to run checks')
->body('Backup set is not available for the active tenant.')
->danger()
->send();
return;
}
$scopeMode = $get('scope_mode') ?? 'all';
$selectedItemIds = ($scopeMode === 'selected')
? ($get('backup_item_ids') ?? null)
: null;
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
$groupMapping = static::normalizeGroupMapping($get('group_mapping'));
$checker = app(RestoreRiskChecker::class);
$outcome = $checker->check(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
groupMapping: $groupMapping,
);
$set('check_summary', $outcome['summary'] ?? [], shouldCallUpdatedHooks: true);
$set('check_results', $outcome['results'] ?? [], shouldCallUpdatedHooks: true);
$set('checks_ran_at', now()->toIso8601String(), shouldCallUpdatedHooks: true);
$summary = $outcome['summary'] ?? [];
$blockers = (int) ($summary['blocking'] ?? 0);
$warnings = (int) ($summary['warning'] ?? 0);
if ($blockers > 0) {
$set('is_dry_run', true, shouldCallUpdatedHooks: true);
}
Notification::make()
->title('Safety checks completed')
->body("Blocking: {$blockers} • Warnings: {$warnings}")
->status($blockers > 0 ? 'danger' : ($warnings > 0 ? 'warning' : 'success'))
->send();
}),
Actions\Action::make('clear_restore_checks')
->label('Clear')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (Get $get): bool => filled($get('check_results')) || filled($get('check_summary')))
->action(function (Set $set): void {
$set('is_dry_run', true, shouldCallUpdatedHooks: true);
$set('acknowledged_impact', false, shouldCallUpdatedHooks: true);
$set('tenant_confirm', null, shouldCallUpdatedHooks: true);
$set('check_summary', null, shouldCallUpdatedHooks: true);
$set('check_results', [], shouldCallUpdatedHooks: true);
$set('checks_ran_at', null, shouldCallUpdatedHooks: true);
}),
])
->helperText('Run checks after defining scope and mapping missing groups.'),
]),
Step::make('Preview')
->description('Dry-run preview')
->schema([
Forms\Components\Hidden::make('preview_summary')
->default(null),
Forms\Components\Hidden::make('preview_ran_at')
->default(null)
->required(),
Forms\Components\ViewField::make('preview_diffs')
->label('Preview')
->default([])
->view('filament.forms.components.restore-run-preview')
->viewData(fn (Get $get): array => [
'summary' => $get('preview_summary'),
'ranAt' => $get('preview_ran_at'),
])
->hintActions([
Actions\Action::make('run_restore_preview')
->label('Generate preview')
->icon('heroicon-o-eye')
->color('gray')
->visible(fn (Get $get): bool => filled($get('backup_set_id')))
->action(function (Get $get, Set $set): void {
$tenant = Tenant::current();
if (! $tenant) {
return;
}
$backupSetId = $get('backup_set_id');
if (! $backupSetId) {
return;
}
$backupSet = BackupSet::find($backupSetId);
if (! $backupSet || $backupSet->tenant_id !== $tenant->id) {
Notification::make()
->title('Unable to generate preview')
->body('Backup set is not available for the active tenant.')
->danger()
->send();
return;
}
$scopeMode = $get('scope_mode') ?? 'all';
$selectedItemIds = ($scopeMode === 'selected')
? ($get('backup_item_ids') ?? null)
: null;
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
$generator = app(RestoreDiffGenerator::class);
$outcome = $generator->generate(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
);
$summary = $outcome['summary'] ?? [];
$diffs = $outcome['diffs'] ?? [];
$set('preview_summary', $summary, shouldCallUpdatedHooks: true);
$set('preview_diffs', $diffs, shouldCallUpdatedHooks: true);
$set('preview_ran_at', $summary['generated_at'] ?? now()->toIso8601String(), shouldCallUpdatedHooks: true);
$policiesChanged = (int) ($summary['policies_changed'] ?? 0);
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
Notification::make()
->title('Preview generated')
->body("Policies: {$policiesChanged}/{$policiesTotal} changed")
->status($policiesChanged > 0 ? 'warning' : 'success')
->send();
}),
Actions\Action::make('clear_restore_preview')
->label('Clear')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (Get $get): bool => filled($get('preview_diffs')) || filled($get('preview_summary')))
->action(function (Set $set): void {
$set('is_dry_run', true, shouldCallUpdatedHooks: true);
$set('acknowledged_impact', false, shouldCallUpdatedHooks: true);
$set('tenant_confirm', null, shouldCallUpdatedHooks: true);
$set('preview_summary', null, shouldCallUpdatedHooks: true);
$set('preview_diffs', [], shouldCallUpdatedHooks: true);
$set('preview_ran_at', null, shouldCallUpdatedHooks: true);
}),
])
->helperText('Generate a normalized diff preview before creating the dry-run restore.'),
]),
Step::make('Confirm & Execute')
->description('Point of no return')
->schema([
Forms\Components\Placeholder::make('confirm_environment')
->label('Environment')
->content(fn (): string => app()->environment('production') ? 'prod' : 'test'),
Forms\Components\Placeholder::make('confirm_tenant_label')
->label('Tenant hard-confirm label')
->content(function (): string {
$tenant = Tenant::current();
if (! $tenant) {
return '';
}
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
return (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
}),
Forms\Components\Toggle::make('is_dry_run')
->label('Preview only (dry-run)')
->default(true)
->reactive()
->disabled(function (Get $get): bool {
if (! filled($get('checks_ran_at'))) {
return true;
}
$summary = $get('check_summary');
if (! is_array($summary)) {
return false;
}
return (int) ($summary['blocking'] ?? 0) > 0;
})
->helperText('Turn OFF to queue a real execution. Execution requires checks + preview + confirmations.'),
Forms\Components\Checkbox::make('acknowledged_impact')
->label('I reviewed the impact (checks + preview)')
->accepted()
->visible(fn (Get $get): bool => $get('is_dry_run') === false),
Forms\Components\TextInput::make('tenant_confirm')
->label('Type the tenant label to confirm execution')
->required(fn (Get $get): bool => $get('is_dry_run') === false)
->visible(fn (Get $get): bool => $get('is_dry_run') === false)
->in(function (): array {
$tenant = Tenant::current();
if (! $tenant) {
return [];
}
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
return [(string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey())];
})
->validationMessages([
'in' => 'Tenant hard-confirm does not match.',
])
->helperText(function (): string {
$tenant = Tenant::current();
if (! $tenant) {
return '';
}
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
$expected = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
return "Type: {$expected}";
}),
]),
];
}
public static function table(Table $table): Table public static function table(Table $table): Table
{ {
return $table return $table
@ -533,11 +1028,72 @@ private static function restoreItemOptionData(?int $backupSetId): array
]; ];
} }
static $cache = []; $cacheKey = sprintf('restore_run_item_options:%s:%s', $tenant->getKey(), $backupSetId);
$cacheKey = $tenant->getKey().':'.$backupSetId;
if (isset($cache[$cacheKey])) { return Cache::store('array')->rememberForever($cacheKey, function () use ($backupSetId, $tenant): array {
return $cache[$cacheKey]; $items = BackupItem::query()
->where('backup_set_id', $backupSetId)
->whereHas('backupSet', fn ($query) => $query->where('tenant_id', $tenant->getKey()))
->where(function ($query) {
$query->whereNull('policy_id')
->orWhereDoesntHave('policy')
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
})
->with(['policy:id,display_name', 'policyVersion:id,version_number,captured_at'])
->get()
->sortBy(function (BackupItem $item) {
$meta = static::typeMeta($item->policy_type);
$category = $meta['category'] ?? 'Policies';
$categoryKey = $category === 'Foundations' ? 'zz-'.$category : $category;
$name = strtolower($item->resolvedDisplayName());
return strtolower($categoryKey.'-'.$name);
});
$options = [];
$descriptions = [];
foreach ($items as $item) {
$meta = static::typeMeta($item->policy_type);
$typeLabel = $meta['label'] ?? $item->policy_type;
$category = $meta['category'] ?? 'Policies';
$restore = $meta['restore'] ?? 'enabled';
$platform = $item->platform ?? $meta['platform'] ?? null;
$displayName = $item->resolvedDisplayName();
$identifier = $item->policy_identifier ?? null;
$versionNumber = $item->policyVersion?->version_number;
$options[$item->id] = $displayName;
$parts = array_filter([
$category,
$typeLabel,
$platform,
"restore: {$restore}",
$versionNumber ? "version: {$versionNumber}" : null,
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
$identifier ? 'id: '.Str::limit($identifier, 24, '...') : null,
]);
$descriptions[$item->id] = implode(' • ', $parts);
}
return [
'options' => $options,
'descriptions' => $descriptions,
];
});
}
/**
* @return array<string, array<int|string, string>>
*/
private static function restoreItemGroupedOptions(?int $backupSetId): array
{
$tenant = Tenant::current();
if (! $tenant || ! $backupSetId) {
return [];
} }
$items = BackupItem::query() $items = BackupItem::query()
@ -548,49 +1104,40 @@ private static function restoreItemOptionData(?int $backupSetId): array
->orWhereDoesntHave('policy') ->orWhereDoesntHave('policy')
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at')); ->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
}) })
->with(['policy:id,display_name', 'policyVersion:id,version_number,captured_at']) ->with(['policy:id,display_name'])
->get() ->get()
->sortBy(function (BackupItem $item) { ->sortBy(function (BackupItem $item) {
$meta = static::typeMeta($item->policy_type); $meta = static::typeMeta($item->policy_type);
$category = $meta['category'] ?? 'Policies'; $category = $meta['category'] ?? 'Policies';
$categoryKey = $category === 'Foundations' ? 'zz-'.$category : $category; $categoryKey = $category === 'Foundations' ? 'zz-'.$category : $category;
$typeLabel = $meta['label'] ?? $item->policy_type;
$platform = $item->platform ?? $meta['platform'] ?? null;
$name = strtolower($item->resolvedDisplayName()); $name = strtolower($item->resolvedDisplayName());
return strtolower($categoryKey.'-'.$name); return strtolower($categoryKey.'-'.$typeLabel.'-'.$platform.'-'.$name);
}); });
$options = []; $groups = [];
$descriptions = [];
foreach ($items as $item) { foreach ($items as $item) {
$meta = static::typeMeta($item->policy_type); $meta = static::typeMeta($item->policy_type);
$typeLabel = $meta['label'] ?? $item->policy_type; $typeLabel = $meta['label'] ?? $item->policy_type;
$category = $meta['category'] ?? 'Policies'; $category = $meta['category'] ?? 'Policies';
$restore = $meta['restore'] ?? 'enabled'; $platform = $item->platform ?? $meta['platform'] ?? 'all';
$platform = $item->platform ?? $meta['platform'] ?? null; $restoreMode = $meta['restore'] ?? 'enabled';
$displayName = $item->resolvedDisplayName();
$identifier = $item->policy_identifier ?? null;
$versionNumber = $item->policyVersion?->version_number;
$options[$item->id] = $displayName; $groupLabel = implode(' • ', array_filter([
$parts = array_filter([
$category, $category,
$typeLabel, $typeLabel,
$platform, $platform,
"restore: {$restore}", $restoreMode === 'preview-only' ? 'preview-only' : null,
$versionNumber ? "version: {$versionNumber}" : null, ]));
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
$identifier ? 'id: '.Str::limit($identifier, 24, '...') : null,
]);
$descriptions[$item->id] = implode(' • ', $parts); $groups[$groupLabel] ??= [];
$groups[$groupLabel][$item->id] = $item->resolvedDisplayName();
} }
return $cache[$cacheKey] = [ return $groups;
'options' => $options,
'descriptions' => $descriptions,
];
} }
public static function createRestoreRun(array $data): RestoreRun public static function createRestoreRun(array $data): RestoreRun
@ -608,15 +1155,170 @@ public static function createRestoreRun(array $data): RestoreRun
/** @var RestoreService $service */ /** @var RestoreService $service */
$service = app(RestoreService::class); $service = app(RestoreService::class);
return $service->execute( $scopeMode = $data['scope_mode'] ?? 'all';
$selectedItemIds = ($scopeMode === 'selected') ? ($data['backup_item_ids'] ?? null) : null;
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
$actorEmail = auth()->user()?->email;
$actorName = auth()->user()?->name;
$isDryRun = (bool) ($data['is_dry_run'] ?? true);
$groupMapping = static::normalizeGroupMapping($data['group_mapping'] ?? null);
$checkSummary = $data['check_summary'] ?? null;
$checkResults = $data['check_results'] ?? null;
$checksRanAt = $data['checks_ran_at'] ?? null;
$previewSummary = $data['preview_summary'] ?? null;
$previewDiffs = $data['preview_diffs'] ?? null;
$previewRanAt = $data['preview_ran_at'] ?? null;
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
$highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
if (! $isDryRun) {
if (! is_array($checkSummary) || ! filled($checksRanAt)) {
throw ValidationException::withMessages([
'check_summary' => 'Run safety checks before executing.',
]);
}
$blocking = (int) ($checkSummary['blocking'] ?? 0);
$hasBlockers = (bool) ($checkSummary['has_blockers'] ?? ($blocking > 0));
if ($blocking > 0 || $hasBlockers) {
throw ValidationException::withMessages([
'check_summary' => 'Blocking checks must be resolved before executing.',
]);
}
if (! filled($previewRanAt)) {
throw ValidationException::withMessages([
'preview_ran_at' => 'Generate preview before executing.',
]);
}
if (! (bool) ($data['acknowledged_impact'] ?? false)) {
throw ValidationException::withMessages([
'acknowledged_impact' => 'Please acknowledge that you reviewed the impact.',
]);
}
$tenantConfirm = $data['tenant_confirm'] ?? null;
if (! is_string($tenantConfirm) || $tenantConfirm !== $highlanderLabel) {
throw ValidationException::withMessages([
'tenant_confirm' => 'Tenant hard-confirm does not match.',
]);
}
}
if ($isDryRun) {
$restoreRun = $service->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
dryRun: true,
actorEmail: $actorEmail,
actorName: $actorName,
groupMapping: $groupMapping,
);
$metadata = $restoreRun->metadata ?? [];
if (is_array($checkSummary)) {
$metadata['check_summary'] = $checkSummary;
}
if (is_array($checkResults)) {
$metadata['check_results'] = $checkResults;
}
if (is_string($checksRanAt) && $checksRanAt !== '') {
$metadata['checks_ran_at'] = $checksRanAt;
}
if (is_array($previewSummary)) {
$metadata['preview_summary'] = $previewSummary;
}
if (is_array($previewDiffs)) {
$metadata['preview_diffs'] = $previewDiffs;
}
if (is_string($previewRanAt) && $previewRanAt !== '') {
$metadata['preview_ran_at'] = $previewRanAt;
}
$restoreRun->update(['metadata' => $metadata]);
return $restoreRun->refresh();
}
$preview = $service->preview($tenant, $backupSet, $selectedItemIds);
$metadata = [
'scope_mode' => $selectedItemIds === null ? 'all' : 'selected',
'environment' => app()->environment('production') ? 'prod' : 'test',
'highlander_label' => $highlanderLabel,
'confirmed_at' => now()->toIso8601String(),
'confirmed_by' => $actorEmail,
'confirmed_by_name' => $actorName,
];
if (is_array($checkSummary)) {
$metadata['check_summary'] = $checkSummary;
}
if (is_array($checkResults)) {
$metadata['check_results'] = $checkResults;
}
if (is_string($checksRanAt) && $checksRanAt !== '') {
$metadata['checks_ran_at'] = $checksRanAt;
}
if (is_array($previewSummary)) {
$metadata['preview_summary'] = $previewSummary;
}
if (is_array($previewDiffs)) {
$metadata['preview_diffs'] = $previewDiffs;
}
if (is_string($previewRanAt) && $previewRanAt !== '') {
$metadata['preview_ran_at'] = $previewRanAt;
}
$restoreRun = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'requested_by' => $actorEmail,
'is_dry_run' => false,
'status' => RestoreRunStatus::Queued->value,
'requested_items' => $selectedItemIds,
'preview' => $preview,
'metadata' => $metadata,
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]);
app(AuditLogger::class)->log(
tenant: $tenant, tenant: $tenant,
backupSet: $backupSet, action: 'restore.queued',
selectedItemIds: $data['backup_item_ids'] ?? null, context: [
dryRun: (bool) ($data['is_dry_run'] ?? true), 'metadata' => [
actorEmail: auth()->user()?->email, 'restore_run_id' => $restoreRun->id,
actorName: auth()->user()?->name, 'backup_set_id' => $backupSet->id,
groupMapping: $data['group_mapping'] ?? [], ],
],
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'restore_run',
resourceId: (string) $restoreRun->id,
status: 'success',
); );
ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName);
return $restoreRun->refresh();
} }
/** /**
@ -703,6 +1405,111 @@ private static function unresolvedGroups(?int $backupSetId, ?array $selectedItem
return $unresolved; return $unresolved;
} }
/**
* @param array<int>|null $selectedItemIds
* @return array<string, string|null>
*/
private static function groupMappingPlaceholders(?int $backupSetId, string $scopeMode, ?array $selectedItemIds, ?Tenant $tenant): array
{
if (! $tenant || ! $backupSetId) {
return [];
}
if ($scopeMode === 'selected' && ($selectedItemIds === null || $selectedItemIds === [])) {
return [];
}
$unresolved = static::unresolvedGroups(
backupSetId: $backupSetId,
selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null,
tenant: $tenant,
);
$placeholders = [];
foreach ($unresolved as $group) {
$groupId = $group['id'] ?? null;
if (! is_string($groupId) || $groupId === '') {
continue;
}
$placeholders[$groupId] = null;
}
return $placeholders;
}
/**
* @return array<string, string>
*/
private static function normalizeGroupMapping(mixed $mapping): array
{
if ($mapping instanceof \Illuminate\Contracts\Support\Arrayable) {
$mapping = $mapping->toArray();
}
if ($mapping instanceof \stdClass) {
$mapping = (array) $mapping;
}
if (! is_array($mapping)) {
return [];
}
$result = [];
if (array_key_exists('group_mapping', $mapping)) {
$nested = $mapping['group_mapping'];
if ($nested instanceof \Illuminate\Contracts\Support\Arrayable) {
$nested = $nested->toArray();
}
if ($nested instanceof \stdClass) {
$nested = (array) $nested;
}
if (is_array($nested)) {
$mapping = $nested;
}
}
foreach ($mapping as $key => $value) {
if (! is_string($key) || $key === '') {
continue;
}
$sourceGroupId = str_starts_with($key, 'group_mapping.')
? substr($key, strlen('group_mapping.'))
: $key;
if ($sourceGroupId === '') {
continue;
}
if ($value instanceof BackedEnum) {
$value = $value->value;
}
if (is_array($value) || $value instanceof \stdClass) {
$value = (array) $value;
$value = $value['value'] ?? $value['id'] ?? null;
}
if (is_string($value)) {
$value = trim($value);
$result[$sourceGroupId] = $value !== '' ? $value : null;
continue;
}
$result[$sourceGroupId] = null;
}
return array_filter($result, static fn (?string $value): bool => is_string($value) && $value !== '');
}
/** /**
* @return array<string, string> * @return array<string, string>
*/ */

View File

@ -3,13 +3,118 @@
namespace App\Filament\Resources\RestoreRunResource\Pages; namespace App\Filament\Resources\RestoreRunResource\Pages;
use App\Filament\Resources\RestoreRunResource; use App\Filament\Resources\RestoreRunResource;
use App\Models\BackupSet;
use App\Models\Tenant;
use Filament\Actions\Action;
use Filament\Resources\Pages\Concerns\HasWizard;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class CreateRestoreRun extends CreateRecord class CreateRestoreRun extends CreateRecord
{ {
use HasWizard;
protected static string $resource = RestoreRunResource::class; protected static string $resource = RestoreRunResource::class;
public function getSteps(): array
{
return RestoreRunResource::getWizardSteps();
}
protected function afterFill(): void
{
$backupSetIdRaw = request()->query('backup_set_id');
if (! is_numeric($backupSetIdRaw)) {
return;
}
$backupSetId = (int) $backupSetIdRaw;
if ($backupSetId <= 0) {
return;
}
$tenant = Tenant::current();
if (! $tenant) {
return;
}
$belongsToTenant = BackupSet::query()
->where('tenant_id', $tenant->id)
->whereKey($backupSetId)
->exists();
if (! $belongsToTenant) {
return;
}
$backupItemIds = $this->normalizeBackupItemIds(request()->query('backup_item_ids'));
$scopeModeRaw = request()->query('scope_mode');
$scopeMode = in_array($scopeModeRaw, ['all', 'selected'], true)
? $scopeModeRaw
: ($backupItemIds !== [] ? 'selected' : 'all');
$this->data['backup_set_id'] = $backupSetId;
$this->form->callAfterStateUpdated('data.backup_set_id');
$this->data['scope_mode'] = $scopeMode;
$this->form->callAfterStateUpdated('data.scope_mode');
if ($scopeMode === 'selected') {
if ($backupItemIds !== []) {
$this->data['backup_item_ids'] = $backupItemIds;
}
$this->form->callAfterStateUpdated('data.backup_item_ids');
}
}
/**
* @return array<int>
*/
private function normalizeBackupItemIds(mixed $raw): array
{
if (is_string($raw)) {
$raw = array_filter(array_map('trim', explode(',', $raw)));
}
if (! is_array($raw)) {
return [];
}
$itemIds = [];
foreach ($raw as $value) {
if (is_int($value) && $value > 0) {
$itemIds[] = $value;
continue;
}
if (is_string($value) && ctype_digit($value)) {
$itemId = (int) $value;
if ($itemId > 0) {
$itemIds[] = $itemId;
}
}
}
$itemIds = array_values(array_unique($itemIds));
sort($itemIds);
return $itemIds;
}
protected function getSubmitFormAction(): Action
{
return parent::getSubmitFormAction()
->label('Create restore run')
->icon('heroicon-o-check-circle');
}
protected function handleRecordCreation(array $data): Model protected function handleRecordCreation(array $data): Model
{ {
return RestoreRunResource::createRestoreRun($data); return RestoreRunResource::createRestoreRun($data);

View 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;
}
}
}

View File

@ -2,6 +2,8 @@
namespace App\Models; namespace App\Models;
use App\Support\RestoreRunStatus;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -35,17 +37,30 @@ public function backupSet(): BelongsTo
return $this->belongsTo(BackupSet::class)->withTrashed(); return $this->belongsTo(BackupSet::class)->withTrashed();
} }
public function scopeDeletable($query) public function scopeDeletable(Builder $query): Builder
{ {
return $query->whereIn('status', ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed']); return $query->whereIn('status', array_map(
static fn (RestoreRunStatus $status): string => $status->value,
[
RestoreRunStatus::Draft,
RestoreRunStatus::Scoped,
RestoreRunStatus::Checked,
RestoreRunStatus::Previewed,
RestoreRunStatus::Completed,
RestoreRunStatus::Partial,
RestoreRunStatus::Failed,
RestoreRunStatus::Cancelled,
RestoreRunStatus::Aborted,
RestoreRunStatus::CompletedWithErrors,
]
));
} }
public function isDeletable(): bool public function isDeletable(): bool
{ {
$status = strtolower(trim((string) $this->status)); $status = RestoreRunStatus::fromString($this->status);
$status = str_replace([' ', '-'], '_', $status);
return in_array($status, ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed'], true); return $status?->isDeletable() ?? false;
} }
// Group mapping helpers // Group mapping helpers

View File

@ -104,13 +104,17 @@ public function makeCurrent(): void
DB::transaction(function () { DB::transaction(function () {
static::activeQuery()->update(['is_current' => false]); 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 public static function current(): self
{ {
$envTenantId = env('INTUNE_TENANT_ID') ?: null; $envTenantId = getenv('INTUNE_TENANT_ID') ?: null;
if ($envTenantId) { if ($envTenantId) {
$tenant = static::activeQuery() $tenant = static::activeQuery()

View File

@ -10,6 +10,9 @@
use App\Services\Intune\DeviceConfigurationPolicyNormalizer; use App\Services\Intune\DeviceConfigurationPolicyNormalizer;
use App\Services\Intune\GroupPolicyConfigurationNormalizer; use App\Services\Intune\GroupPolicyConfigurationNormalizer;
use App\Services\Intune\SettingsCatalogPolicyNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer;
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
use App\Services\Intune\WindowsUpdateRingNormalizer;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@ -40,6 +43,9 @@ public function register(): void
DeviceConfigurationPolicyNormalizer::class, DeviceConfigurationPolicyNormalizer::class,
GroupPolicyConfigurationNormalizer::class, GroupPolicyConfigurationNormalizer::class,
SettingsCatalogPolicyNormalizer::class, SettingsCatalogPolicyNormalizer::class,
WindowsFeatureUpdateProfileNormalizer::class,
WindowsQualityUpdateProfileNormalizer::class,
WindowsUpdateRingNormalizer::class,
], ],
'policy-type-normalizers' 'policy-type-normalizers'
); );

View File

@ -77,6 +77,16 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
$metadata = Arr::except($response->data, ['payload']); $metadata = Arr::except($response->data, ['payload']);
$metadataWarnings = $metadata['warnings'] ?? []; $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') { if ($policy->policy_type === 'settingsCatalogPolicy') {
[$payload, $metadata] = $this->hydrateSettingsCatalog( [$payload, $metadata] = $this->hydrateSettingsCatalog(
tenantIdentifier: $tenantIdentifier, 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 private function isMetadataOnlyPolicyType(string $policyType): bool
{ {
foreach (config('tenantpilot.supported_policy_types', []) as $type) { foreach (config('tenantpilot.supported_policy_types', []) as $type) {

View 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;
}
}

View 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);
}
}

View File

@ -40,6 +40,10 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
{ {
$this->assertActiveContext($tenant, $backupSet); $this->assertActiveContext($tenant, $backupSet);
if ($selectedItemIds === []) {
$selectedItemIds = null;
}
$items = $this->loadItems($backupSet, $selectedItemIds); $items = $this->loadItems($backupSet, $selectedItemIds);
[$foundationItems, $policyItems] = $this->splitItems($items); [$foundationItems, $policyItems] = $this->splitItems($items);
@ -180,6 +184,45 @@ public function executeFromPolicyVersion(
); );
} }
public function executeForRun(
RestoreRun $restoreRun,
Tenant $tenant,
BackupSet $backupSet,
?string $actorEmail = null,
?string $actorName = null,
): RestoreRun {
$this->assertActiveContext($tenant, $backupSet);
if ($restoreRun->tenant_id !== $tenant->id) {
throw new \InvalidArgumentException('Restore run does not belong to the provided tenant.');
}
if ($restoreRun->backup_set_id !== $backupSet->id) {
throw new \InvalidArgumentException('Restore run does not belong to the provided backup set.');
}
if (in_array($restoreRun->status, ['completed', 'partial', 'failed', 'cancelled'], true)) {
throw new \RuntimeException('Restore run is already finished.');
}
$selectedItemIds = is_array($restoreRun->requested_items) ? $restoreRun->requested_items : null;
if ($selectedItemIds === []) {
$selectedItemIds = null;
}
return $this->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
dryRun: (bool) $restoreRun->is_dry_run,
actorEmail: $actorEmail,
actorName: $actorName,
groupMapping: $restoreRun->group_mapping ?? [],
existingRun: $restoreRun,
);
}
public function execute( public function execute(
Tenant $tenant, Tenant $tenant,
BackupSet $backupSet, BackupSet $backupSet,
@ -188,26 +231,65 @@ public function execute(
?string $actorEmail = null, ?string $actorEmail = null,
?string $actorName = null, ?string $actorName = null,
array $groupMapping = [], array $groupMapping = [],
?RestoreRun $existingRun = null,
): RestoreRun { ): RestoreRun {
$this->assertActiveContext($tenant, $backupSet); $this->assertActiveContext($tenant, $backupSet);
if ($selectedItemIds === []) {
$selectedItemIds = null;
}
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
$items = $this->loadItems($backupSet, $selectedItemIds); $items = $this->loadItems($backupSet, $selectedItemIds);
[$foundationItems, $policyItems] = $this->splitItems($items); [$foundationItems, $policyItems] = $this->splitItems($items);
$preview = $this->preview($tenant, $backupSet, $selectedItemIds); $preview = $this->preview($tenant, $backupSet, $selectedItemIds);
$restoreRun = RestoreRun::create([ $wizardMetadata = [
'tenant_id' => $tenant->id, 'scope_mode' => $selectedItemIds === null ? 'all' : 'selected',
'backup_set_id' => $backupSet->id, 'environment' => app()->environment('production') ? 'prod' : 'test',
'requested_by' => $actorEmail, 'highlander_label' => (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()),
'is_dry_run' => $dryRun, ];
'status' => 'running',
'requested_items' => $selectedItemIds, if ($existingRun !== null) {
'preview' => $preview, if ($existingRun->tenant_id !== $tenant->id) {
'started_at' => CarbonImmutable::now(), throw new \InvalidArgumentException('Restore run does not belong to the provided tenant.');
'metadata' => [], }
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]); if ($existingRun->backup_set_id !== $backupSet->id) {
throw new \InvalidArgumentException('Restore run does not belong to the provided backup set.');
}
$metadata = array_merge($wizardMetadata, $existingRun->metadata ?? []);
$existingRun->update([
'requested_by' => $existingRun->requested_by ?? $actorEmail,
'is_dry_run' => $dryRun,
'status' => 'running',
'requested_items' => $selectedItemIds,
'preview' => $preview,
'results' => null,
'failure_reason' => null,
'started_at' => $existingRun->started_at ?? CarbonImmutable::now(),
'completed_at' => null,
'metadata' => $metadata,
'group_mapping' => $groupMapping !== [] ? $groupMapping : ($existingRun->group_mapping ?? null),
]);
$restoreRun = $existingRun->refresh();
} else {
$restoreRun = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'requested_by' => $actorEmail,
'is_dry_run' => $dryRun,
'status' => 'running',
'requested_items' => $selectedItemIds,
'preview' => $preview,
'started_at' => CarbonImmutable::now(),
'metadata' => $wizardMetadata,
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]);
}
if ($groupMapping !== []) { if ($groupMapping !== []) {
$this->auditLogger->log( $this->auditLogger->log(
@ -473,6 +555,23 @@ public function execute(
$payload, $payload,
$graphOptions + ['method' => $updateMethod] $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 { } else {
$response = $this->graphClient->applyPolicy( $response = $this->graphClient->applyPolicy(
$item->policy_type, $item->policy_type,
@ -740,12 +839,12 @@ public function execute(
'status' => $status, 'status' => $status,
'results' => $results, 'results' => $results,
'completed_at' => CarbonImmutable::now(), 'completed_at' => CarbonImmutable::now(),
'metadata' => [ 'metadata' => array_merge($restoreRun->metadata ?? [], [
'failed' => $hardFailures, 'failed' => $hardFailures,
'non_applied' => $nonApplied, 'non_applied' => $nonApplied,
'total' => $totalCount, 'total' => $totalCount,
'foundations_skipped' => $foundationSkipped, 'foundations_skipped' => $foundationSkipped,
], ]),
]); ]);
$this->auditLogger->log( $this->auditLogger->log(

View 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;
}
}
}

View File

@ -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,
];
}
}

View 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;
}
}

View File

@ -29,6 +29,14 @@ protected static function odataTypeMap(): array
'windows' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', 'windows' => '#microsoft.graph.windowsUpdateForBusinessConfiguration',
'all' => '#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' => [ 'deviceCompliancePolicy' => [
'windows' => '#microsoft.graph.windows10CompliancePolicy', 'windows' => '#microsoft.graph.windows10CompliancePolicy',
'ios' => '#microsoft.graph.iosCompliancePolicy', 'ios' => '#microsoft.graph.iosCompliancePolicy',

View 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);
}
}

View File

@ -143,6 +143,13 @@
'update_method' => 'PATCH', 'update_method' => 'PATCH',
'id_field' => 'id', 'id_field' => 'id',
'hydration' => 'properties', 'hydration' => 'properties',
'update_strip_keys' => [
'version',
'qualityUpdatesPauseStartDate',
'featureUpdatesPauseStartDate',
'qualityUpdatesWillBeRolledBack',
'featureUpdatesWillBeRolledBack',
],
'assignments_list_path' => '/deviceManagement/deviceConfigurations/{id}/assignments', 'assignments_list_path' => '/deviceManagement/deviceConfigurations/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceConfigurations/{id}/assign', 'assignments_create_path' => '/deviceManagement/deviceConfigurations/{id}/assign',
'assignments_create_method' => 'POST', 'assignments_create_method' => 'POST',
@ -153,6 +160,52 @@
'supports_scope_tags' => true, 'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds', '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' => [ 'deviceCompliancePolicy' => [
'resource' => 'deviceManagement/deviceCompliancePolicies', 'resource' => 'deviceManagement/deviceCompliancePolicies',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'],

View File

@ -8,7 +8,7 @@
'category' => 'Configuration', 'category' => 'Configuration',
'platform' => 'all', 'platform' => 'all',
'endpoint' => 'deviceManagement/deviceConfigurations', 'endpoint' => 'deviceManagement/deviceConfigurations',
'filter' => "@odata.type ne '#microsoft.graph.windowsUpdateForBusinessConfiguration'", 'filter' => "not isof('microsoft.graph.windowsUpdateForBusinessConfiguration')",
'backup' => 'full', 'backup' => 'full',
'restore' => 'enabled', 'restore' => 'enabled',
'risk' => 'medium', 'risk' => 'medium',
@ -39,11 +39,31 @@
'category' => 'Update Management', 'category' => 'Update Management',
'platform' => 'windows', 'platform' => 'windows',
'endpoint' => 'deviceManagement/deviceConfigurations', 'endpoint' => 'deviceManagement/deviceConfigurations',
'filter' => "@odata.type eq '#microsoft.graph.windowsUpdateForBusinessConfiguration'", 'filter' => "isof('microsoft.graph.windowsUpdateForBusinessConfiguration')",
'backup' => 'full', 'backup' => 'full',
'restore' => 'enabled', 'restore' => 'enabled',
'risk' => 'medium-high', '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', 'type' => 'deviceCompliancePolicy',
'label' => 'Device Compliance', 'label' => 'Device Compliance',
@ -130,7 +150,7 @@
'category' => 'Enrollment', 'category' => 'Enrollment',
'platform' => 'all', 'platform' => 'all',
'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations',
'filter' => "@odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'", 'filter' => "isof('microsoft.graph.windows10EnrollmentCompletionPageConfiguration')",
'backup' => 'full', 'backup' => 'full',
'restore' => 'enabled', 'restore' => 'enabled',
'risk' => 'medium', 'risk' => 'medium',

View File

@ -18,7 +18,9 @@
</include> </include>
</source> </source>
<php> <php>
<ini name="memory_limit" value="512M"/>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="INTUNE_TENANT_ID" value="" force="true"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/> <env name="BROADCAST_CONNECTION" value="null"/>

View File

@ -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>

View File

@ -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>

View File

@ -7,37 +7,39 @@ ## Phase 0 — Specs (this PR)
- [x] T001 Create `spec.md`, `plan.md`, `tasks.md` for Feature 011. - [x] T001 Create `spec.md`, `plan.md`, `tasks.md` for Feature 011.
## Phase 1 — Data Model + Status Semantics ## Phase 1 — Data Model + Status Semantics
- [ ] T002 Define RestoreRun lifecycle statuses and transitions (draft→scoped→checked→previewed→queued→running→completed|partial|failed). - [x] 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). - [x] 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] T004 Freeze `environment` + `highlander_label` at run creation for audit.
## Phase 2 — Filament Wizard (Create Restore Run) ## Phase 2 — Filament Wizard (Create Restore Run)
- [ ] T005 Replace current single-form create with a 5-step wizard (Step 15 as in spec). - [x] T005 Replace current single-form create with a 5-step wizard (Step 15 as in spec).
- [ ] T006 Ensure changing `backup_set_id` resets downstream wizard state. - [x] 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] T007 Enforce “dry-run default ON” and keep execute disabled until all gates satisfied.
## Phase 3 — Restore Scope UX ## Phase 3 — Restore Scope UX
- [ ] T008 Implement scoped selection UI grouped by policy type + platform with search and bulk toggle. - [x] 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. - [x] T009 Mark preview-only types clearly and ensure they never execute.
- [ ] T010 Ensure foundations are discoverable (assignment filters, scope tags, notification templates). - [x] T010 Ensure foundations are discoverable (assignment filters, scope tags, notification templates).
## Phase 4 — Safety & Conflict Checks ## Phase 4 — Safety & Conflict Checks
- [ ] T011 Implement `RestoreRiskChecker` (server-side) and persist `check_summary` + `check_results`. - [x] 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] T012 Render check results with severity (blocking/warning/safe) and block execute when blockers exist.
## Phase 5 — Preview (Diff) ## Phase 5 — Preview (Diff)
- [ ] T013 Implement `RestoreDiffGenerator` using `PolicyNormalizer` + `VersionDiff`. - [x] T013 Implement `RestoreDiffGenerator` using `PolicyNormalizer` + `VersionDiff`.
- [ ] T014 Persist preview summary (and per-item diffs with safe limits) and require preview completion before execute. - [x] T014 Persist preview summary (and per-item diffs with safe limits) and require preview completion before execute.
## Phase 6 — Confirm & Execute ## Phase 6 — Confirm & Execute
- [ ] T015 Implement Step 5 confirmations (ack checkbox + tenant hard-confirm). - [x] T015 Implement Step 5 confirmations (ack checkbox + tenant hard-confirm).
- [ ] T016 Execute restore via a queued Job (preferred) and update statuses + timestamps. - [x] 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] T017 Persist execution outcomes and ensure audit logging entries exist for execution start/finish.
## Phase 7 — Tests + Formatting ## Phase 7 — Tests + Formatting
- [ ] T018 Add Pest tests for wizard gating rules and status transitions. - [x] T018 Add Pest tests for wizard gating rules and status transitions.
- [ ] T019 Add Pest tests for safety checks persistence and blocking behavior. - [x] T019 Add Pest tests for safety checks persistence and blocking behavior.
- [ ] T020 Add Pest tests for preview summary generation. - [x] T020 Add Pest tests for preview summary generation.
- [ ] T021 Run `./vendor/bin/pint --dirty`. - [x] T021 Run `./vendor/bin/pint --dirty`.
- [ ] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist). - [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.

View 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.

View 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.

View 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.

View File

@ -11,6 +11,7 @@
test('progress widget shows running operations for current tenant and user', function () { test('progress widget shows running operations for current tenant and user', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$user = User::factory()->create(); $user = User::factory()->create();
// Own running op // Own running op
@ -39,9 +40,6 @@
'status' => 'running', 'status' => 'running',
]); ]);
// $tenant->makeCurrent();
$tenant->forceFill(['is_current' => true])->save();
auth()->login($user); // Login user explicitly for auth()->id() call in component auth()->login($user); // Login user explicitly for auth()->id() call in component
Livewire::actingAs($user) Livewire::actingAs($user)

View 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);
});

View File

@ -12,7 +12,7 @@
test('policy detail shows app protection settings in readable sections', function () { test('policy detail shows app protection settings in readable sections', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,

View File

@ -73,7 +73,7 @@ public function request(string $method, string $path, array $options = []): Grap
}); });
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
]); ]);

View File

@ -23,6 +23,8 @@
'name' => 'Tenant', 'name' => 'Tenant',
]); ]);
$tenant->makeCurrent();
$backupSet = BackupSet::create([ $backupSet = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'name' => 'Set 1', 'name' => 'Set 1',
@ -60,6 +62,8 @@
'name' => 'Tenant 2', 'name' => 'Tenant 2',
]); ]);
$tenant->makeCurrent();
$backupSet = BackupSet::create([ $backupSet = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'name' => 'Set with restore', 'name' => 'Set with restore',
@ -93,6 +97,8 @@
'name' => 'Tenant Force', 'name' => 'Tenant Force',
]); ]);
$tenant->makeCurrent();
$backupSet = BackupSet::create([ $backupSet = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'name' => 'Set force', 'name' => 'Set force',
@ -132,6 +138,8 @@
'name' => 'Tenant Restore Backup Set', 'name' => 'Tenant Restore Backup Set',
]); ]);
$tenant->makeCurrent();
$backupSet = BackupSet::create([ $backupSet = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'name' => 'Set restore', 'name' => 'Set restore',
@ -171,6 +179,8 @@
'name' => 'Tenant Restore Run', 'name' => 'Tenant Restore Run',
]); ]);
$tenant->makeCurrent();
$backupSet = BackupSet::create([ $backupSet = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'name' => 'Set RR', 'name' => 'Set RR',
@ -207,6 +217,8 @@
'name' => 'Tenant Restore Restore Run', 'name' => 'Tenant Restore Restore Run',
]); ]);
$tenant->makeCurrent();
$backupSet = BackupSet::create([ $backupSet = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'name' => 'Set for restore run restore', 'name' => 'Set for restore run restore',

View File

@ -13,7 +13,7 @@
test('malformed snapshot renders warning on policy and version detail', function () { test('malformed snapshot renders warning on policy and version detail', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,

View File

@ -50,7 +50,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
}); });
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,

View File

@ -8,7 +8,7 @@
test('policies are listed for the active tenant', function () { test('policies are listed for the active tenant', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
]); ]);

View File

@ -12,13 +12,12 @@
test('policy detail shows normalized settings section', function () { test('policy detail shows normalized settings section', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([

View File

@ -12,13 +12,12 @@
test('policy version detail renders tabs and scroll-safe blocks', function () { test('policy version detail renders tabs and scroll-safe blocks', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([

View 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');
});

View File

@ -12,13 +12,12 @@
test('policy version view shows scope tags even when assignments are missing', function () { test('policy version view shows scope tags even when assignments are missing', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([

View File

@ -12,13 +12,12 @@
test('policy version detail shows raw and normalized settings', function () { test('policy version detail shows raw and normalized settings', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([

View File

@ -11,11 +11,13 @@
test('policy versions render with timeline data', function () { test('policy versions render with timeline data', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
]); ]);
$tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'external_id' => 'policy-1', 'external_id' => 'policy-1',

View File

@ -13,13 +13,12 @@
it('shows Settings tab for Settings Catalog policy', function () { it('shows Settings tab for Settings Catalog policy', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
@ -86,13 +85,12 @@
it('shows display names instead of definition IDs', function () { it('shows display names instead of definition IDs', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
@ -143,13 +141,12 @@
it('shows fallback prettified labels when definitions not cached', function () { it('shows fallback prettified labels when definitions not cached', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
@ -195,13 +192,12 @@
it('shows tabbed layout for non-Settings Catalog policies', function () { it('shows tabbed layout for non-Settings Catalog policies', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
@ -242,7 +238,7 @@
// T034: Test display names shown (not definition IDs) // T034: Test display names shown (not definition IDs)
it('displays setting display names instead of raw definition IDs', function () { it('displays setting display names instead of raw definition IDs', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'is_current' => true, 'is_current' => true,
]); ]);
@ -296,7 +292,7 @@
// T035: Test values formatted correctly // T035: Test values formatted correctly
it('formats setting values correctly based on type', function () { it('formats setting values correctly based on type', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'is_current' => true, 'is_current' => true,
]); ]);
@ -370,7 +366,7 @@
// T036: Test search/filter functionality // T036: Test search/filter functionality
it('search filters settings in real-time', function () { it('search filters settings in real-time', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'is_current' => true, 'is_current' => true,
]); ]);
@ -433,7 +429,7 @@
// T037: Test graceful degradation for missing definitions // T037: Test graceful degradation for missing definitions
it('shows prettified fallback labels when definitions are not cached', function () { it('shows prettified fallback labels when definitions are not cached', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'is_current' => true, 'is_current' => true,
]); ]);

View File

@ -1,5 +1,6 @@
<?php <?php
use App\Filament\Resources\RestoreRunResource;
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun; use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Models\BackupItem; use App\Models\BackupItem;
use App\Models\BackupSet; use App\Models\BackupSet;
@ -11,7 +12,7 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
test('restore selection shows readable labels and descriptions', function () { test('restore selection options are grouped and filter ignored policies', function () {
$tenant = Tenant::factory()->create(['status' => 'active']); $tenant = Tenant::factory()->create(['status' => 'active']);
$tenant->makeCurrent(); $tenant->makeCurrent();
@ -22,6 +23,13 @@
'display_name' => 'Policy Display', 'display_name' => 'Policy Display',
'platform' => 'windows', 'platform' => 'windows',
]); ]);
$previewOnlyPolicy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-preview-only',
'policy_type' => 'conditionalAccessPolicy',
'display_name' => 'Conditional Access Policy',
'platform' => 'all',
]);
$ignoredPolicy = Policy::factory()->create([ $ignoredPolicy = Policy::factory()->create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'external_id' => 'policy-ignored', 'external_id' => 'policy-ignored',
@ -32,10 +40,10 @@
]); ]);
$backupSet = BackupSet::factory()->for($tenant)->create([ $backupSet = BackupSet::factory()->for($tenant)->create([
'item_count' => 2, 'item_count' => 4,
]); ]);
BackupItem::factory() $policyItem = BackupItem::factory()
->for($tenant) ->for($tenant)
->for($backupSet) ->for($backupSet)
->state([ ->state([
@ -47,7 +55,7 @@
]) ])
->create(); ->create();
BackupItem::factory() $ignoredPolicyItem = BackupItem::factory()
->for($tenant) ->for($tenant)
->for($backupSet) ->for($backupSet)
->state([ ->state([
@ -59,7 +67,7 @@
]) ])
->create(); ->create();
BackupItem::factory() $scopeTagItem = BackupItem::factory()
->for($tenant) ->for($tenant)
->for($backupSet) ->for($backupSet)
->state([ ->state([
@ -77,6 +85,18 @@
]) ])
->create(); ->create();
$previewOnlyItem = BackupItem::factory()
->for($tenant)
->for($backupSet)
->state([
'policy_id' => $previewOnlyPolicy->id,
'policy_identifier' => $previewOnlyPolicy->external_id,
'policy_type' => $previewOnlyPolicy->policy_type,
'platform' => $previewOnlyPolicy->platform,
'payload' => ['id' => $previewOnlyPolicy->external_id],
])
->create();
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
@ -84,13 +104,33 @@
->fillForm([ ->fillForm([
'backup_set_id' => $backupSet->id, 'backup_set_id' => $backupSet->id,
]) ])
->assertSee('Policy Display') ->goToNextWizardStep()
->assertDontSee('Ignored Policy') ->fillForm([
->assertSee('Scope Tag Alpha') 'scope_mode' => 'selected',
->assertSee('Settings Catalog Policy') ])
->assertSee('Scope Tag') ->assertFormFieldVisible('backup_item_ids')
->assertSee('restore: enabled')
->assertSee('id: policy-1')
->assertSee('id: tag-1')
->assertSee('Include foundations'); ->assertSee('Include foundations');
$method = new ReflectionMethod(RestoreRunResource::class, 'restoreItemGroupedOptions');
$method->setAccessible(true);
$groupedOptions = $method->invoke(null, $backupSet->id);
expect($groupedOptions)->toHaveKey('Configuration • Settings Catalog Policy • windows');
expect($groupedOptions)->toHaveKey('Foundations • Scope Tag • all');
expect($groupedOptions)->toHaveKey('Conditional Access • Conditional Access • all • preview-only');
$flattenedOptions = collect($groupedOptions)
->reduce(fn (array $carry, array $options): array => $carry + $options, []);
expect($flattenedOptions)->toHaveKey($policyItem->id);
expect($flattenedOptions[$policyItem->id])->toBe('Policy Display');
expect($flattenedOptions)->not->toHaveKey($ignoredPolicyItem->id);
expect($flattenedOptions)->toHaveKey($scopeTagItem->id);
expect($flattenedOptions[$scopeTagItem->id])->toBe('Scope Tag Alpha');
expect($flattenedOptions)->toHaveKey($previewOnlyItem->id);
expect($flattenedOptions[$previewOnlyItem->id])->toBe('Conditional Access Policy');
}); });

View File

@ -13,13 +13,12 @@
test('settings catalog policies render a normalized settings table', function () { test('settings catalog policies render a normalized settings table', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([

View File

@ -75,16 +75,11 @@ public function request(string $method, string $path, array $options = []): Grap
app()->instance(GraphClientInterface::class, new SettingsCatalogFakeGraphClient($responses)); app()->instance(GraphClientInterface::class, new SettingsCatalogFakeGraphClient($responses));
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, '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(); $tenant->makeCurrent();
expect(Tenant::current()->id)->toBe($tenant->id); expect(Tenant::current()->id)->toBe($tenant->id);

View File

@ -13,13 +13,12 @@
test('settings catalog settings render as a filament table with details action', function () { test('settings catalog settings render as a filament table with details action', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([

View 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');
});

View 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');
});

View 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');
});

View File

@ -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 () { test('sync skips managed app configurations from app protection inventory', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant'), 'tenant_id' => 'test-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,

View File

@ -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 () { test('sync revives ignored policies when they exist in Intune', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant'), 'tenant_id' => 'test-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'metadata' => [], 'metadata' => [],
'is_current' => true, '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 () { test('sync creates new policies even if ignored ones exist with same external_id', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant-2'), 'tenant_id' => 'test-tenant-2',
'name' => 'Test Tenant 2', 'name' => 'Test Tenant 2',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,

View 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');
});

View File

@ -77,12 +77,23 @@
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
Livewire::test(CreateRestoreRun::class) $component = Livewire::test(CreateRestoreRun::class)
->fillForm([ ->fillForm([
'backup_set_id' => $backupSet->id, 'backup_set_id' => $backupSet->id,
'backup_item_ids' => [$backupItem->id],
]) ])
->assertFormFieldVisible('group_mapping.source-group-1'); ->goToNextWizardStep()
->fillForm([
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id],
]);
$mapping = $component->get('data.group_mapping');
expect($mapping)->toBeArray();
expect(array_key_exists('source-group-1', $mapping))->toBeTrue();
expect($mapping['source-group-1'])->toBeNull();
$component->assertFormFieldVisible('group_mapping.source-group-1');
}); });
test('restore wizard persists group mapping selections', function () { test('restore wizard persists group mapping selections', function () {
@ -150,12 +161,19 @@
Livewire::test(CreateRestoreRun::class) Livewire::test(CreateRestoreRun::class)
->fillForm([ ->fillForm([
'backup_set_id' => $backupSet->id, 'backup_set_id' => $backupSet->id,
])
->goToNextWizardStep()
->fillForm([
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id], 'backup_item_ids' => [$backupItem->id],
'group_mapping' => [ 'group_mapping' => [
'source-group-1' => 'target-group-1', 'source-group-1' => 'target-group-1',
], ],
'is_dry_run' => true,
]) ])
->goToNextWizardStep()
->goToNextWizardStep()
->callFormComponentAction('preview_diffs', 'run_restore_preview')
->goToNextWizardStep()
->call('create') ->call('create')
->assertHasNoFormErrors(); ->assertHasNoFormErrors();

View 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);
});

View 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');
});

View 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);
});

View 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');
});

View File

@ -4,4 +4,13 @@
use Illuminate\Foundation\Testing\TestCase as BaseTestCase; 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']);
}
}

View File

@ -115,7 +115,7 @@ public function request(string $method, string $path, array $options = []): Grap
expect($result['items'][1]['source_id'])->toBe('filter-2'); expect($result['items'][1]['source_id'])->toBe('filter-2');
expect($client->requests[0]['path'])->toBe('deviceManagement/assignmentFilters'); 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]['path'])->toBe('deviceManagement/assignmentFilters?$skiptoken=abc');
expect($client->requests[1]['options']['query'])->toBe([]); expect($client->requests[1]['options']['query'])->toBe([]);
}); });

View File

@ -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'])->toContain('roleScopeTagIds');
expect($client->requests[0][3]['select'])->not->toContain('@odata.type'); 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');
});

View File

@ -108,3 +108,27 @@ function restoreIntuneTenantId(string|false $original): void
restoreIntuneTenantId($originalEnv); 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);
});