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
5 changed files with 995 additions and 8 deletions
Showing only changes of commit cd76fa5dd7 - Show all commits

View File

@ -13,6 +13,7 @@
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\RestoreRiskChecker;
use App\Services\Intune\RestoreService; use App\Services\Intune\RestoreService;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
@ -176,6 +177,9 @@ public static function getWizardSteps(): array
$set('backup_item_ids', null); $set('backup_item_ids', null);
$set('group_mapping', []); $set('group_mapping', []);
$set('is_dry_run', true); $set('is_dry_run', true);
$set('check_summary', null);
$set('check_results', []);
$set('checks_ran_at', null);
}) })
->required(), ->required(),
]), ]),
@ -192,6 +196,9 @@ public static function getWizardSteps(): array
->reactive() ->reactive()
->afterStateUpdated(function (Set $set, $state): void { ->afterStateUpdated(function (Set $set, $state): void {
$set('group_mapping', []); $set('group_mapping', []);
$set('check_summary', null);
$set('check_results', []);
$set('checks_ran_at', null);
if ($state === 'all') { if ($state === 'all') {
$set('backup_item_ids', null); $set('backup_item_ids', null);
@ -211,7 +218,12 @@ public static function getWizardSteps(): array
->optionsLimit(300) ->optionsLimit(300)
->options(fn (Get $get) => static::restoreItemGroupedOptions($get('backup_set_id'))) ->options(fn (Get $get) => static::restoreItemGroupedOptions($get('backup_set_id')))
->reactive() ->reactive()
->afterStateUpdated(fn (Set $set) => $set('group_mapping', [])) ->afterStateUpdated(function (Set $set): void {
$set('group_mapping', []);
$set('check_summary', null);
$set('check_results', []);
$set('checks_ran_at', null);
})
->visible(fn (Get $get): bool => $get('scope_mode') === 'selected') ->visible(fn (Get $get): bool => $get('scope_mode') === 'selected')
->required(fn (Get $get): bool => $get('scope_mode') === 'selected') ->required(fn (Get $get): bool => $get('scope_mode') === 'selected')
->hintActions([ ->hintActions([
@ -275,6 +287,12 @@ public static function getWizardSteps(): array
->searchable() ->searchable()
->getSearchResultsUsing(fn (string $search) => static::targetGroupOptions($tenant, $search)) ->getSearchResultsUsing(fn (string $search) => static::targetGroupOptions($tenant, $search))
->getOptionLabelUsing(fn (?string $value) => static::resolveTargetGroupLabel($tenant, $value)) ->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);
})
->helperText('Choose a target group or select Skip.'); ->helperText('Choose a target group or select Skip.');
}, $unresolved); }, $unresolved);
}) })
@ -302,11 +320,98 @@ public static function getWizardSteps(): array
}), }),
]), ]),
Step::make('Safety & Conflict Checks') Step::make('Safety & Conflict Checks')
->description('Defensive checks (Phase 4)') ->description('Is this dangerous?')
->schema([ ->schema([
Forms\Components\Placeholder::make('safety_checks_placeholder') Forms\Components\Hidden::make('check_summary')
->label('Status') ->default(null),
->content('Safety & conflict checks will be added in Phase 4.'), 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 = $get('group_mapping') ?? [];
$groupMapping = is_array($groupMapping) ? $groupMapping : [];
$groupMapping = collect($groupMapping)
->map(fn ($value) => is_string($value) ? $value : null)
->all();
$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);
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('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') Step::make('Preview')
->description('Dry-run preview (Phase 5)') ->description('Dry-run preview (Phase 5)')
@ -858,7 +963,7 @@ public static function createRestoreRun(array $data): RestoreRun
? ($data['backup_item_ids'] ?? null) ? ($data['backup_item_ids'] ?? null)
: null; : null;
return $service->execute( $restoreRun = $service->execute(
tenant: $tenant, tenant: $tenant,
backupSet: $backupSet, backupSet: $backupSet,
selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null, selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null,
@ -867,6 +972,32 @@ public static function createRestoreRun(array $data): RestoreRun
actorName: auth()->user()?->name, actorName: auth()->user()?->name,
groupMapping: $data['group_mapping'] ?? [], groupMapping: $data['group_mapping'] ?? [],
); );
$checkSummary = $data['check_summary'] ?? null;
$checkResults = $data['check_results'] ?? null;
$checksRanAt = $data['checks_ran_at'] ?? null;
if (is_array($checkSummary) || is_array($checkResults) || (is_string($checksRanAt) && $checksRanAt !== '')) {
$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;
}
$restoreRun->update([
'metadata' => $metadata,
]);
}
return $restoreRun->refresh();
} }
/** /**

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

@ -0,0 +1,118 @@
@php
$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
<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>

View File

@ -22,8 +22,8 @@ ## Phase 3 — Restore Scope UX
- [x] 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`. - [ ] T013 Implement `RestoreDiffGenerator` using `PolicyNormalizer` + `VersionDiff`.

View File

@ -0,0 +1,130 @@
<?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()
->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);
});