From 6844bc1c17bd255047c2bb01875ad9d3c99c1904 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Tue, 30 Dec 2025 02:56:28 +0100 Subject: [PATCH 01/13] spec: restore run wizard --- specs/011-restore-run-wizard/plan.md | 75 +++++++++ specs/011-restore-run-wizard/spec.md | 224 ++++++++++++++++++++++++++ specs/011-restore-run-wizard/tasks.md | 43 +++++ 3 files changed, 342 insertions(+) create mode 100644 specs/011-restore-run-wizard/plan.md create mode 100644 specs/011-restore-run-wizard/spec.md create mode 100644 specs/011-restore-run-wizard/tasks.md diff --git a/specs/011-restore-run-wizard/plan.md b/specs/011-restore-run-wizard/plan.md new file mode 100644 index 0000000..36c191d --- /dev/null +++ b/specs/011-restore-run-wizard/plan.md @@ -0,0 +1,75 @@ +# Implementation Plan: Restore Run Wizard (011) + +**Branch**: `feat/011-restore-run-wizard` | **Date**: 2025-12-30 +**Input**: Feature specification in `specs/011-restore-run-wizard/spec.md` + +## Summary +Refactor Restore Run creation into a **Filament Wizard** that enforces **Safety First**: +source → scope → safety checks → preview → confirm + execute. + +Leverage existing restore primitives (`RestoreService::preview()` / `RestoreService::execute()`) and incrementally introduce: +- structured **risk checks** +- **diff preview** artifacts/summaries +- stronger **execution gating** + audit fields + +## Technical Context (current code) +- Filament Resource: `app/Filament/Resources/RestoreRunResource.php` (single form today) +- Restore engine: `app/Services/Intune/RestoreService.php` (preview + execute) +- Diff tools: `app/Services/Intune/PolicyNormalizer.php` + `app/Services/Intune/VersionDiff.php` +- Data model: `restore_runs` already stores `preview`, `results`, `metadata`, `requested_items` + +## Phase 1 — Data + State Model (Wizard-ready) +- Define restore run lifecycle statuses (string enum values). +- Decide what is stored as dedicated columns vs `restore_runs.metadata` JSON. +- Add minimal persistence for wizard state: + - `scope_mode`, `check_summary`, `check_results`, `preview_summary`, `confirmed_at/by`, `environment`, `highlander_label`. + +**Checkpoint**: RestoreRun can represent wizard progression and persist computations. + +## Phase 2 — Filament Wizard UI (Create Restore Run) +- Replace the single Create form with a 5-step wizard UI. +- Implement step-level validation and state resets (changing backup set resets downstream). +- Keep dry-run default ON, and make execution UI unavailable until the wizard rules are satisfied. + +**Checkpoint**: Wizard is usable end-to-end in dry-run. + +## Phase 3 — Restore Scope Builder (Selection UX) +- Build grouped selection UI for BackupItems (type/platform), with search and “select all”. +- Clearly mark: + - foundations vs policies + - preview-only types + - items missing policy_version linkage / snapshot completeness hints (optional) + +**Checkpoint**: Scoping is explicit, scalable, and safe. + +## Phase 4 — Safety & Conflict Checks (RestoreRiskChecker) +- Implement server-side checks for the chosen scope. +- Persist results on the RestoreRun and display with severity badges. +- Block execution if blockers exist. + +**Checkpoint**: Defensive layer in place; blockers stop execution. + +## Phase 5 — Preview (RestoreDiffGenerator) +- Generate a diff summary (minimum) comparing backup snapshot vs current target state. +- Persist preview summary (and optionally per-item diffs with limits). +- Require preview completion before allowing execute. + +**Checkpoint**: Preview step is a hard gate for execute and is auditable. + +## Phase 6 — Confirm & Execute +- Add explicit confirmations: + - “I reviewed the impact” + - tenant hard-confirm (Highlander) + - environment badge (frozen at run creation) +- Execute restore via queue job (preferred) or synchronous execution (only if queue is out of scope for MVP). +- Update run statuses and persist outcomes. + +**Checkpoint**: Execution is safe, gated, and traceable. + +## Phase 7 — Tests + QA +- Pest feature tests for: + - wizard gating rules (execute disabled until conditions satisfied) + - safety checks persistence and blocking behavior + - preview summary generation +- Run targeted tests and Pint. + diff --git a/specs/011-restore-run-wizard/spec.md b/specs/011-restore-run-wizard/spec.md new file mode 100644 index 0000000..96b6b9b --- /dev/null +++ b/specs/011-restore-run-wizard/spec.md @@ -0,0 +1,224 @@ +# Feature Specification: Restore Run Wizard (011) + +**Feature Branch**: `feat/011-restore-run-wizard` +**Created**: 2025-12-30 +**Status**: Draft +**Input**: Restore Run Wizard requirements (Safety First / Defensive Restore) + +## Overview +Implement **Restore Runs** as a **multi-step Wizard** (instead of a single “Create Restore Run” form) to enforce **Safety First / Defensive Restore**. + +Restore is a high-risk workflow. The wizard must guide admins through explicit checkpoints: +source selection → scoping → safety checks → preview → confirmation + execution. + +## Problem Statement +The current Restore Run creation is a single form that can lead to: +- picking the wrong backup source +- restoring too broad a scope unintentionally +- executing without a structured “risk + preview + explicit confirmation” flow + +## Goals +- Make restore a **deliberate, stepwise** process with strong defaults. +- Make **dry-run** the default, and keep “Execute” disabled until all safety gates are satisfied. +- Add **server-side safety/conflict checks** and persist results for auditability. +- Provide a **preview** (diff summary at minimum) before allowing execution. + +## Non-Goals (v1) +- Approval workflows / multi-person approvals (but design must not block future addition). +- Perfect diff UX parity with Intune (basic normalized diff output is enough). +- A generic wizard framework (restore-specific implementation is fine). + +--- + +## UX Principles +- **Dry-run default = ON** +- Wizard progression should slow the user down and force explicit decisions. +- “Execute” stays disabled until: + - Preview has been completed + - No blocking checks exist + - “I reviewed the impact” checkbox is checked + - Tenant hard-confirm matches (Highlander principle) + +--- + +## Wizard Steps + +### Step 1 — Select Backup Set (Source of Truth) +**Question:** “What are we restoring from?” + +**Inputs** +- Backup Set (required) + +**Read-only** +- Snapshot timestamp +- Tenant name +- Count of policies/items +- Types (Config / Security / Scripts …) + +**Validation** +- `backup_set_id` is required +- Changing the backup set resets downstream state (scope, checks, preview, confirmation) + +### Step 2 — Define Restore Scope (Selectivity) +**Question:** “What exactly should be restored?” + +**Inputs** +- Scope mode: `all` (default) or `selected` +- If `selected`: item multiselect with search + select all + +**UI** +- Prefer grouped by **type** and **platform** +- Mark “preview-only” types clearly +- Foundations should be discoverable (scope tags, assignment filters, notification templates) + +**Notes** +- “Empty = all” only when scope mode is `all` (not when `selected`) + +### Step 3 — Safety & Conflict Checks (Defensive Layer) +**Question:** “Is this dangerous?” + +**Checks (server-side, persisted)** +- Target policy missing in target tenant? +- Target policy newer than backup? (staleness / overwrite risk) +- Assignments conflicts (e.g., mapping required / orphaned groups) +- Scope tag conflicts (mapping required / missing) +- Preview-only policies included in scope (should be warned and auto-dry-run) + +**Severity** +- ❌ blocking +- ⚠️ warning +- ✅ safe + +**Rules** +- Blocking checks prevent execution. +- Wizard may allow proceeding to preview, but must never allow execute while blockers exist. + +### Step 4 — Preview (Dry-Run Simulation) +**Question:** “What would happen?” + +**Outputs** +- Diff summary (at minimum): + - X policies changed + - Y assignments changed + - Z scope tags changed +- Per-item normalized diff (nice-to-have for v1, but plan for it) + +**Defaults** +- “Preview only (Dry-run)” is ON by default + +### Step 5 — Confirm & Execute (Point of No Return) +**Question:** “Do you really want to do this?” + +**Confirmations** +- Checkbox: “I reviewed the impact” +- Tenant hard-confirm input (must match tenant display identifier) +- Environment badge (Prod/Test) highly visible (frozen at run start for audit) + +**Rules** +- Execute disabled if: + - `dry_run = true` + - blockers exist + - tenant confirm mismatch + - acknowledgement unchecked + +--- + +## Domain Model (v1-aligned) +We already have a `restore_runs` aggregate (`restore_runs` table) with: +- `backup_set_id`, `requested_items`, `preview`, `results`, `status`, `metadata`, timestamps, and `group_mapping`. + +**v1 approach** +- Keep the existing primary key type (bigint) to avoid a disruptive migration. +- Extend the lifecycle/status semantics and persist wizard computations (checks + diff summaries) in structured fields: + - Prefer adding dedicated JSON columns only if needed; otherwise use `metadata` for wizard state. + +### RestoreRun Lifecycle (proposed statuses) +`draft → scoped → checked → previewed → queued → running → completed|partial|failed|cancelled` + +### Persisted Wizard State (minimum) +- `backup_set_id` (existing) +- `requested_items` (selected IDs, existing) +- `metadata.scope_mode` (`all|selected`) +- `metadata.environment` (`prod|test`) +- `metadata.highlander_label` (tenant identifier string, frozen) +- `metadata.check_summary` + `metadata.check_results` (Step 3) +- `metadata.preview_summary` + `metadata.preview_diffs` (Step 4; diffs may be truncated/limited) +- `metadata.confirmed_at`, `metadata.confirmed_by` (Step 5) + +--- + +## Services / Responsibilities +- **RestoreScopeBuilder**: build selectable restore items (grouped, searchable), include foundations & mark preview-only. +- **RestoreRiskChecker**: run safety checks, return structured results + summary. +- **RestoreDiffGenerator**: generate diff summary (and optionally per-item diffs) for preview. +- **RestoreExecutor**: execute restore (idempotent, tenant/run locking), write detailed outcomes. +- **RestoreRunPolicy**: enforce invariants (no execution without preview + confirmations). + +--- + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Wizard-driven Restore Run (Priority: P1) +As an admin, I can create a restore run via a 5-step wizard and I cannot accidentally execute without preview + explicit confirmations. + +**Why this priority**: This is the safety foundation; without it, restore remains risky UX. + +**Independent Test**: In Filament, create a restore run with dry-run, see checks + preview, and confirm execute stays disabled until gates satisfied. + +**Acceptance Scenarios** +1. **Given** I select a backup set, **When** I move to the next step, **Then** scope/check/preview state is reset when I change the backup set again. +2. **Given** I keep dry-run enabled, **When** I reach Step 5, **Then** Execute is disabled. +3. **Given** I disable dry-run, **When** I have not completed preview, **Then** Execute is disabled. + +--- + +### User Story 2 — Safety Checks block execution (Priority: P1) +As an admin, I see blocking vs warning checks, and execution is blocked when blockers exist. + +**Why this priority**: Defensive restore requires an explicit risk layer. + +**Independent Test**: Create a scope that triggers a blocking check and verify execution cannot proceed. + +**Acceptance Scenarios** +1. **Given** a blocking check exists, **When** I reach Step 5, **Then** Execute remains disabled and blockers are visible. +2. **Given** only warnings exist, **When** I acknowledge impact and hard-confirm tenant, **Then** I can execute (dry-run off). + +--- + +### User Story 3 — Preview diff summary (Priority: P2) +As an admin, I can preview what would change before executing restore. + +**Why this priority**: A restore without preview is operationally unsafe. + +**Independent Test**: Run Step 4 preview and verify diff summary is computed and persisted on the RestoreRun. + +**Acceptance Scenarios** +1. **Given** I scoped items, **When** I run preview, **Then** I see a summary (changed policies count) and it persists on the restore run. + +--- + +## Edge Cases +- Very large backup sets (hundreds/thousands of items): selection/search must remain responsive. +- Switching backup set mid-flow resets downstream state safely. +- Policies not present in target tenant: shown as warning/blocker depending on restore mode. +- RBAC-limited tenant setup: checks must clearly show “inventory/restore may be partial”. + +--- + +## Functional Requirements +- **FR-011.1**: System MUST implement Restore Run creation as a 5-step wizard in Filament. +- **FR-011.2**: System MUST default `dry_run = true` and prevent execution while dry-run is enabled. +- **FR-011.3**: System MUST run server-side safety checks and persist results (summary + details) for audit. +- **FR-011.4**: System MUST generate at least a diff summary on preview and persist it. +- **FR-011.5**: System MUST require explicit acknowledgement + tenant hard-confirm before allowing execution. +- **FR-011.6**: System MUST freeze environment badge and tenant label for audit on run creation. +- **FR-011.7**: System MUST keep execution disabled if any blocking checks exist. +- **FR-011.8**: System MUST record execution outcomes and leave an auditable trail (existing audit log patterns). + +--- + +## Success Criteria +- **SC-011.1**: Admins can only execute after preview + confirmations; no accidental execution path exists. +- **SC-011.2**: Blocking checks reliably prevent execution. +- **SC-011.3**: Preview produces a persisted summary for every run. + diff --git a/specs/011-restore-run-wizard/tasks.md b/specs/011-restore-run-wizard/tasks.md new file mode 100644 index 0000000..323e85b --- /dev/null +++ b/specs/011-restore-run-wizard/tasks.md @@ -0,0 +1,43 @@ +# Tasks: Restore Run Wizard (011) + +**Branch**: `feat/011-restore-run-wizard` | **Date**: 2025-12-30 +**Input**: `specs/011-restore-run-wizard/spec.md`, `specs/011-restore-run-wizard/plan.md` + +## Phase 0 — Specs (this PR) +- [x] T001 Create `spec.md`, `plan.md`, `tasks.md` for Feature 011. + +## Phase 1 — Data Model + Status Semantics +- [ ] T002 Define RestoreRun lifecycle statuses and transitions (draft→scoped→checked→previewed→queued→running→completed|partial|failed). +- [ ] T003 Add minimal persistence for wizard state (prefer JSON in `restore_runs.metadata` unless columns are required). +- [ ] T004 Freeze `environment` + `highlander_label` at run creation for audit. + +## Phase 2 — Filament Wizard (Create Restore Run) +- [ ] T005 Replace current single-form create with a 5-step wizard (Step 1–5 as in spec). +- [ ] T006 Ensure changing `backup_set_id` resets downstream wizard state. +- [ ] T007 Enforce “dry-run default ON” and keep execute disabled until all gates satisfied. + +## Phase 3 — Restore Scope UX +- [ ] T008 Implement scoped selection UI grouped by policy type + platform with search and bulk toggle. +- [ ] T009 Mark preview-only types clearly and ensure they never execute. +- [ ] T010 Ensure foundations are discoverable (assignment filters, scope tags, notification templates). + +## Phase 4 — Safety & Conflict Checks +- [ ] T011 Implement `RestoreRiskChecker` (server-side) and persist `check_summary` + `check_results`. +- [ ] T012 Render check results with severity (blocking/warning/safe) and block execute when blockers exist. + +## Phase 5 — Preview (Diff) +- [ ] T013 Implement `RestoreDiffGenerator` using `PolicyNormalizer` + `VersionDiff`. +- [ ] T014 Persist preview summary (and per-item diffs with safe limits) and require preview completion before execute. + +## Phase 6 — Confirm & Execute +- [ ] T015 Implement Step 5 confirmations (ack checkbox + tenant hard-confirm). +- [ ] T016 Execute restore via a queued Job (preferred) and update statuses + timestamps. +- [ ] T017 Persist execution outcomes and ensure audit logging entries exist for execution start/finish. + +## Phase 7 — Tests + Formatting +- [ ] T018 Add Pest tests for wizard gating rules and status transitions. +- [ ] T019 Add Pest tests for safety checks persistence and blocking behavior. +- [ ] T020 Add Pest tests for preview summary generation. +- [ ] T021 Run `./vendor/bin/pint --dirty`. +- [ ] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist). + -- 2.45.2 From 3cf4aa2cf4766b24600c0aca2141683430b41094 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Tue, 30 Dec 2025 03:23:48 +0100 Subject: [PATCH 02/13] feat: restore run wizard phase 1 scaffold --- app/Filament/Resources/RestoreRunResource.php | 268 ++++++++++++++---- .../Pages/CreateRestoreRun.php | 8 + app/Models/RestoreRun.php | 25 +- app/Services/Intune/RestoreService.php | 20 +- app/Support/RestoreRunStatus.php | 73 +++++ specs/011-restore-run-wizard/tasks.md | 11 +- .../Filament/RestoreItemSelectionTest.php | 4 + tests/Feature/RestoreGroupMappingTest.php | 13 + .../Feature/RestoreRunWizardMetadataTest.php | 88 ++++++ 9 files changed, 444 insertions(+), 66 deletions(-) create mode 100644 app/Support/RestoreRunStatus.php create mode 100644 tests/Feature/RestoreRunWizardMetadataTest.php diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 3f7088d..d0f2d69 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -26,12 +26,14 @@ use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Set; +use Filament\Schemas\Components\Wizard\Step; use Filament\Schemas\Schema; use Filament\Tables; use Filament\Tables\Contracts\HasTable; use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; use UnitEnum; @@ -69,8 +71,10 @@ public static function form(Schema $schema): Schema }) ->reactive() ->afterStateUpdated(function (Set $set): void { - $set('backup_item_ids', []); + $set('scope_mode', 'all'); + $set('backup_item_ids', null); $set('group_mapping', []); + $set('is_dry_run', true); }) ->required(), Forms\Components\CheckboxList::make('backup_item_ids') @@ -137,6 +141,164 @@ public static function form(Schema $schema): Schema ]); } + /** + * @return array + */ + 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): void { + $set('scope_mode', 'all'); + $set('backup_item_ids', null); + $set('group_mapping', []); + $set('is_dry_run', true); + }) + ->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, $state): void { + $set('group_mapping', []); + + if ($state === 'all') { + $set('backup_item_ids', null); + } + }) + ->required(), + Forms\Components\CheckboxList::make('backup_item_ids') + ->label('Items to restore') + ->options(fn (Get $get) => static::restoreItemOptionData($get('backup_set_id'))['options']) + ->descriptions(fn (Get $get) => static::restoreItemOptionData($get('backup_set_id'))['descriptions']) + ->columns(1) + ->searchable() + ->bulkToggleable() + ->reactive() + ->afterStateUpdated(fn (Set $set) => $set('group_mapping', [])) + ->visible(fn (Get $get): bool => $get('scope_mode') === 'selected') + ->required(fn (Get $get): bool => $get('scope_mode') === 'selected') + ->helperText('Search by name, type, or ID. Include foundations (scope tags, assignment filters) with policies to re-map IDs.'), + 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)) + ->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('Defensive checks (Phase 4)') + ->schema([ + Forms\Components\Placeholder::make('safety_checks_placeholder') + ->label('Status') + ->content('Safety & conflict checks will be added in Phase 4.'), + ]), + Step::make('Preview') + ->description('Dry-run preview (Phase 5)') + ->schema([ + Forms\Components\Toggle::make('is_dry_run') + ->label('Preview only (dry-run)') + ->default(true), + Forms\Components\Placeholder::make('preview_placeholder') + ->label('Preview') + ->content('Preview diff summary will be added in Phase 5.'), + ]), + Step::make('Confirm & Execute') + ->description('Explicit confirmations (Phase 6)') + ->schema([ + Forms\Components\Placeholder::make('confirm_placeholder') + ->label('Execution') + ->content('Execution confirmations and gating will be added in Phase 6.'), + ]), + ]; + } + public static function table(Table $table): Table { return $table @@ -533,64 +695,61 @@ private static function restoreItemOptionData(?int $backupSetId): array ]; } - static $cache = []; - $cacheKey = $tenant->getKey().':'.$backupSetId; + $cacheKey = sprintf('restore_run_item_options:%s:%s', $tenant->getKey(), $backupSetId); - if (isset($cache[$cacheKey])) { - return $cache[$cacheKey]; - } + return Cache::store('array')->rememberForever($cacheKey, function () use ($backupSetId, $tenant): array { + $items = BackupItem::query() + ->where('backup_set_id', $backupSetId) + ->whereHas('backupSet', fn ($query) => $query->where('tenant_id', $tenant->getKey())) + ->where(function ($query) { + $query->whereNull('policy_id') + ->orWhereDoesntHave('policy') + ->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at')); + }) + ->with(['policy:id,display_name', 'policyVersion:id,version_number,captured_at']) + ->get() + ->sortBy(function (BackupItem $item) { + $meta = static::typeMeta($item->policy_type); + $category = $meta['category'] ?? 'Policies'; + $categoryKey = $category === 'Foundations' ? 'zz-'.$category : $category; + $name = strtolower($item->resolvedDisplayName()); - $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) { + 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'; - $categoryKey = $category === 'Foundations' ? 'zz-'.$category : $category; - $name = strtolower($item->resolvedDisplayName()); + $restore = $meta['restore'] ?? 'enabled'; + $platform = $item->platform ?? $meta['platform'] ?? null; + $displayName = $item->resolvedDisplayName(); + $identifier = $item->policy_identifier ?? null; + $versionNumber = $item->policyVersion?->version_number; - return strtolower($categoryKey.'-'.$name); - }); + $options[$item->id] = $displayName; - $options = []; - $descriptions = []; + $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, + ]); - 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; + $descriptions[$item->id] = implode(' • ', $parts); + } - $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 $cache[$cacheKey] = [ - 'options' => $options, - 'descriptions' => $descriptions, - ]; + return [ + 'options' => $options, + 'descriptions' => $descriptions, + ]; + }); } public static function createRestoreRun(array $data): RestoreRun @@ -608,10 +767,15 @@ public static function createRestoreRun(array $data): RestoreRun /** @var RestoreService $service */ $service = app(RestoreService::class); + $scopeMode = $data['scope_mode'] ?? 'all'; + $selectedItemIds = ($scopeMode === 'selected') + ? ($data['backup_item_ids'] ?? null) + : null; + return $service->execute( tenant: $tenant, backupSet: $backupSet, - selectedItemIds: $data['backup_item_ids'] ?? null, + selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null, dryRun: (bool) ($data['is_dry_run'] ?? true), actorEmail: auth()->user()?->email, actorName: auth()->user()?->name, diff --git a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php index 2c22eb1..1ddc73c 100644 --- a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php +++ b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php @@ -3,13 +3,21 @@ namespace App\Filament\Resources\RestoreRunResource\Pages; use App\Filament\Resources\RestoreRunResource; +use Filament\Resources\Pages\Concerns\HasWizard; use Filament\Resources\Pages\CreateRecord; use Illuminate\Database\Eloquent\Model; class CreateRestoreRun extends CreateRecord { + use HasWizard; + protected static string $resource = RestoreRunResource::class; + public function getSteps(): array + { + return RestoreRunResource::getWizardSteps(); + } + protected function handleRecordCreation(array $data): Model { return RestoreRunResource::createRestoreRun($data); diff --git a/app/Models/RestoreRun.php b/app/Models/RestoreRun.php index 28945c4..e4d3ce0 100644 --- a/app/Models/RestoreRun.php +++ b/app/Models/RestoreRun.php @@ -2,6 +2,8 @@ namespace App\Models; +use App\Support\RestoreRunStatus; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -35,17 +37,30 @@ public function backupSet(): BelongsTo return $this->belongsTo(BackupSet::class)->withTrashed(); } - public function scopeDeletable($query) + public function scopeDeletable(Builder $query): Builder { - return $query->whereIn('status', ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed']); + return $query->whereIn('status', array_map( + static fn (RestoreRunStatus $status): string => $status->value, + [ + RestoreRunStatus::Draft, + RestoreRunStatus::Scoped, + RestoreRunStatus::Checked, + RestoreRunStatus::Previewed, + RestoreRunStatus::Completed, + RestoreRunStatus::Partial, + RestoreRunStatus::Failed, + RestoreRunStatus::Cancelled, + RestoreRunStatus::Aborted, + RestoreRunStatus::CompletedWithErrors, + ] + )); } public function isDeletable(): bool { - $status = strtolower(trim((string) $this->status)); - $status = str_replace([' ', '-'], '_', $status); + $status = RestoreRunStatus::fromString($this->status); - return in_array($status, ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed'], true); + return $status?->isDeletable() ?? false; } // Group mapping helpers diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index d5082b1..2b8aaef 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -40,6 +40,10 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt { $this->assertActiveContext($tenant, $backupSet); + if ($selectedItemIds === []) { + $selectedItemIds = null; + } + $items = $this->loadItems($backupSet, $selectedItemIds); [$foundationItems, $policyItems] = $this->splitItems($items); @@ -191,11 +195,21 @@ public function execute( ): RestoreRun { $this->assertActiveContext($tenant, $backupSet); + if ($selectedItemIds === []) { + $selectedItemIds = null; + } + $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; $items = $this->loadItems($backupSet, $selectedItemIds); [$foundationItems, $policyItems] = $this->splitItems($items); $preview = $this->preview($tenant, $backupSet, $selectedItemIds); + $wizardMetadata = [ + 'scope_mode' => $selectedItemIds === null ? 'all' : 'selected', + 'environment' => app()->environment('production') ? 'prod' : 'test', + 'highlander_label' => (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()), + ]; + $restoreRun = RestoreRun::create([ 'tenant_id' => $tenant->id, 'backup_set_id' => $backupSet->id, @@ -205,7 +219,7 @@ public function execute( 'requested_items' => $selectedItemIds, 'preview' => $preview, 'started_at' => CarbonImmutable::now(), - 'metadata' => [], + 'metadata' => $wizardMetadata, 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, ]); @@ -740,12 +754,12 @@ public function execute( 'status' => $status, 'results' => $results, 'completed_at' => CarbonImmutable::now(), - 'metadata' => [ + 'metadata' => array_merge($restoreRun->metadata ?? [], [ 'failed' => $hardFailures, 'non_applied' => $nonApplied, 'total' => $totalCount, 'foundations_skipped' => $foundationSkipped, - ], + ]), ]); $this->auditLogger->log( diff --git a/app/Support/RestoreRunStatus.php b/app/Support/RestoreRunStatus.php new file mode 100644 index 0000000..727c4e5 --- /dev/null +++ b/app/Support/RestoreRunStatus.php @@ -0,0 +1,73 @@ + 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); + } +} diff --git a/specs/011-restore-run-wizard/tasks.md b/specs/011-restore-run-wizard/tasks.md index 323e85b..a8066f8 100644 --- a/specs/011-restore-run-wizard/tasks.md +++ b/specs/011-restore-run-wizard/tasks.md @@ -7,13 +7,13 @@ ## Phase 0 — Specs (this PR) - [x] T001 Create `spec.md`, `plan.md`, `tasks.md` for Feature 011. ## Phase 1 — Data Model + Status Semantics -- [ ] T002 Define RestoreRun lifecycle statuses and transitions (draft→scoped→checked→previewed→queued→running→completed|partial|failed). -- [ ] T003 Add minimal persistence for wizard state (prefer JSON in `restore_runs.metadata` unless columns are required). -- [ ] T004 Freeze `environment` + `highlander_label` at run creation for audit. +- [x] T002 Define RestoreRun lifecycle statuses and transitions (draft→scoped→checked→previewed→queued→running→completed|partial|failed). +- [x] T003 Add minimal persistence for wizard state (prefer JSON in `restore_runs.metadata` unless columns are required). +- [x] T004 Freeze `environment` + `highlander_label` at run creation for audit. ## Phase 2 — Filament Wizard (Create Restore Run) -- [ ] T005 Replace current single-form create with a 5-step wizard (Step 1–5 as in spec). -- [ ] T006 Ensure changing `backup_set_id` resets downstream wizard state. +- [x] T005 Replace current single-form create with a 5-step wizard (Step 1–5 as in spec). +- [x] T006 Ensure changing `backup_set_id` resets downstream wizard state. - [ ] T007 Enforce “dry-run default ON” and keep execute disabled until all gates satisfied. ## Phase 3 — Restore Scope UX @@ -40,4 +40,3 @@ ## Phase 7 — Tests + Formatting - [ ] T020 Add Pest tests for preview summary generation. - [ ] T021 Run `./vendor/bin/pint --dirty`. - [ ] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist). - diff --git a/tests/Feature/Filament/RestoreItemSelectionTest.php b/tests/Feature/Filament/RestoreItemSelectionTest.php index a3ce170..63ec789 100644 --- a/tests/Feature/Filament/RestoreItemSelectionTest.php +++ b/tests/Feature/Filament/RestoreItemSelectionTest.php @@ -84,6 +84,10 @@ ->fillForm([ 'backup_set_id' => $backupSet->id, ]) + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', + ]) ->assertSee('Policy Display') ->assertDontSee('Ignored Policy') ->assertSee('Scope Tag Alpha') diff --git a/tests/Feature/RestoreGroupMappingTest.php b/tests/Feature/RestoreGroupMappingTest.php index 2397796..260cc99 100644 --- a/tests/Feature/RestoreGroupMappingTest.php +++ b/tests/Feature/RestoreGroupMappingTest.php @@ -80,6 +80,10 @@ Livewire::test(CreateRestoreRun::class) ->fillForm([ 'backup_set_id' => $backupSet->id, + ]) + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', 'backup_item_ids' => [$backupItem->id], ]) ->assertFormFieldVisible('group_mapping.source-group-1'); @@ -150,12 +154,21 @@ Livewire::test(CreateRestoreRun::class) ->fillForm([ 'backup_set_id' => $backupSet->id, + ]) + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', 'backup_item_ids' => [$backupItem->id], 'group_mapping' => [ 'source-group-1' => 'target-group-1', ], + ]) + ->goToNextWizardStep() + ->goToNextWizardStep() + ->fillForm([ 'is_dry_run' => true, ]) + ->goToNextWizardStep() ->call('create') ->assertHasNoFormErrors(); diff --git a/tests/Feature/RestoreRunWizardMetadataTest.php b/tests/Feature/RestoreRunWizardMetadataTest.php new file mode 100644 index 0000000..1f81df3 --- /dev/null +++ b/tests/Feature/RestoreRunWizardMetadataTest.php @@ -0,0 +1,88 @@ + '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() + ->fillForm([ + 'is_dry_run' => true, + ]) + ->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'); +}); -- 2.45.2 From e7d21e0eb8f360959a5310554e57b28651dd580b Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Tue, 30 Dec 2025 10:44:18 +0100 Subject: [PATCH 03/13] feat: gate restore wizard to dry-run --- app/Filament/Resources/RestoreRunResource.php | 6 ++- .../Pages/CreateRestoreRun.php | 8 +++ specs/011-restore-run-wizard/tasks.md | 5 +- .../Feature/RestoreRunWizardMetadataTest.php | 51 +++++++++++++++++++ 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index d0f2d69..5b0faec 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -284,7 +284,9 @@ public static function getWizardSteps(): array ->schema([ Forms\Components\Toggle::make('is_dry_run') ->label('Preview only (dry-run)') - ->default(true), + ->default(true) + ->disabled() + ->helperText('Execution will be enabled once checks, preview, and confirmations are implemented (Phase 6).'), Forms\Components\Placeholder::make('preview_placeholder') ->label('Preview') ->content('Preview diff summary will be added in Phase 5.'), @@ -776,7 +778,7 @@ public static function createRestoreRun(array $data): RestoreRun tenant: $tenant, backupSet: $backupSet, selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null, - dryRun: (bool) ($data['is_dry_run'] ?? true), + dryRun: true, actorEmail: auth()->user()?->email, actorName: auth()->user()?->name, groupMapping: $data['group_mapping'] ?? [], diff --git a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php index 1ddc73c..0fff9d4 100644 --- a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php +++ b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources\RestoreRunResource\Pages; use App\Filament\Resources\RestoreRunResource; +use Filament\Actions\Action; use Filament\Resources\Pages\Concerns\HasWizard; use Filament\Resources\Pages\CreateRecord; use Illuminate\Database\Eloquent\Model; @@ -18,6 +19,13 @@ public function getSteps(): array return RestoreRunResource::getWizardSteps(); } + protected function getSubmitFormAction(): Action + { + return parent::getSubmitFormAction() + ->label('Create preview (dry-run)') + ->icon('heroicon-o-eye'); + } + protected function handleRecordCreation(array $data): Model { return RestoreRunResource::createRestoreRun($data); diff --git a/specs/011-restore-run-wizard/tasks.md b/specs/011-restore-run-wizard/tasks.md index a8066f8..2a0893f 100644 --- a/specs/011-restore-run-wizard/tasks.md +++ b/specs/011-restore-run-wizard/tasks.md @@ -14,7 +14,7 @@ ## Phase 1 — Data Model + Status Semantics ## Phase 2 — Filament Wizard (Create Restore Run) - [x] T005 Replace current single-form create with a 5-step wizard (Step 1–5 as in spec). - [x] T006 Ensure changing `backup_set_id` resets downstream wizard state. -- [ ] 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 - [ ] T008 Implement scoped selection UI grouped by policy type + platform with search and bulk toggle. @@ -40,3 +40,6 @@ ## Phase 7 — Tests + Formatting - [ ] T020 Add Pest tests for preview summary generation. - [ ] T021 Run `./vendor/bin/pint --dirty`. - [ ] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist). + +## Phase 8 — Policy Version Entry Point (later) +- [ ] 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. diff --git a/tests/Feature/RestoreRunWizardMetadataTest.php b/tests/Feature/RestoreRunWizardMetadataTest.php index 1f81df3..b8f13e1 100644 --- a/tests/Feature/RestoreRunWizardMetadataTest.php +++ b/tests/Feature/RestoreRunWizardMetadataTest.php @@ -86,3 +86,54 @@ expect($run->metadata['environment'])->toBe('test'); expect($run->metadata['highlander_label'])->toBe('Tenant One'); }); + +test('restore run wizard always creates dry-run previews in phase 2', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-2', + 'name' => 'Tenant Two', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $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' => null, + 'policy_identifier' => 'policy-2', + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows', + 'payload' => ['id' => 'policy-2'], + 'metadata' => [ + 'displayName' => 'Backup Policy Two', + ], + ]); + + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(CreateRestoreRun::class) + ->fillForm([ + 'backup_set_id' => $backupSet->id, + ]) + ->goToNextWizardStep() + ->goToNextWizardStep() + ->goToNextWizardStep() + ->set('data.is_dry_run', false) + ->goToNextWizardStep() + ->call('create') + ->assertHasNoFormErrors(); + + $run = RestoreRun::query()->latest('id')->first(); + + expect($run)->not->toBeNull(); + expect($run->is_dry_run)->toBeTrue(); + expect($run->status)->toBe('previewed'); +}); -- 2.45.2 From 2b9b649549c5639ca59e67984ee59ed842fbf0cd Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Tue, 30 Dec 2025 21:29:41 +0100 Subject: [PATCH 04/13] feat: group restore item selection --- app/Filament/Resources/RestoreRunResource.php | 96 +++++++++++++++++-- specs/011-restore-run-wizard/tasks.md | 10 +- .../Filament/RestoreItemSelectionTest.php | 62 +++++++++--- 3 files changed, 144 insertions(+), 24 deletions(-) diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 5b0faec..b27cdb9 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -195,21 +195,50 @@ public static function getWizardSteps(): array if ($state === 'all') { $set('backup_item_ids', null); + + return; } + + $set('backup_item_ids', []); }) ->required(), - Forms\Components\CheckboxList::make('backup_item_ids') + Forms\Components\Select::make('backup_item_ids') ->label('Items to restore') - ->options(fn (Get $get) => static::restoreItemOptionData($get('backup_set_id'))['options']) - ->descriptions(fn (Get $get) => static::restoreItemOptionData($get('backup_set_id'))['descriptions']) - ->columns(1) + ->multiple() ->searchable() - ->bulkToggleable() + ->searchValues() + ->searchDebounce(400) + ->optionsLimit(300) + ->options(fn (Get $get) => static::restoreItemGroupedOptions($get('backup_set_id'))) ->reactive() ->afterStateUpdated(fn (Set $set) => $set('group_mapping', [])) ->visible(fn (Get $get): bool => $get('scope_mode') === 'selected') ->required(fn (Get $get): bool => $get('scope_mode') === 'selected') - ->helperText('Search by name, type, or ID. Include foundations (scope tags, assignment filters) with policies to re-map IDs.'), + ->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 { @@ -754,6 +783,61 @@ private static function restoreItemOptionData(?int $backupSetId): array }); } + /** + * @return array> + */ + private static function restoreItemGroupedOptions(?int $backupSetId): array + { + $tenant = Tenant::current(); + + if (! $tenant || ! $backupSetId) { + return []; + } + + $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']) + ->get() + ->sortBy(function (BackupItem $item) { + $meta = static::typeMeta($item->policy_type); + $category = $meta['category'] ?? 'Policies'; + $categoryKey = $category === 'Foundations' ? 'zz-'.$category : $category; + $typeLabel = $meta['label'] ?? $item->policy_type; + $platform = $item->platform ?? $meta['platform'] ?? null; + $name = strtolower($item->resolvedDisplayName()); + + return strtolower($categoryKey.'-'.$typeLabel.'-'.$platform.'-'.$name); + }); + + $groups = []; + + foreach ($items as $item) { + $meta = static::typeMeta($item->policy_type); + $typeLabel = $meta['label'] ?? $item->policy_type; + $category = $meta['category'] ?? 'Policies'; + $platform = $item->platform ?? $meta['platform'] ?? 'all'; + $restoreMode = $meta['restore'] ?? 'enabled'; + + $groupLabel = implode(' • ', array_filter([ + $category, + $typeLabel, + $platform, + $restoreMode === 'preview-only' ? 'preview-only' : null, + ])); + + $groups[$groupLabel] ??= []; + $groups[$groupLabel][$item->id] = $item->resolvedDisplayName(); + } + + return $groups; + } + public static function createRestoreRun(array $data): RestoreRun { /** @var Tenant $tenant */ diff --git a/specs/011-restore-run-wizard/tasks.md b/specs/011-restore-run-wizard/tasks.md index 2a0893f..53f7a83 100644 --- a/specs/011-restore-run-wizard/tasks.md +++ b/specs/011-restore-run-wizard/tasks.md @@ -17,9 +17,9 @@ ## Phase 2 — Filament Wizard (Create Restore Run) - [x] T007 Enforce “dry-run default ON” and keep execute disabled until all gates satisfied. ## Phase 3 — Restore Scope UX -- [ ] T008 Implement scoped selection UI grouped by policy type + platform with search and bulk toggle. -- [ ] T009 Mark preview-only types clearly and ensure they never execute. -- [ ] T010 Ensure foundations are discoverable (assignment filters, scope tags, notification templates). +- [x] T008 Implement scoped selection UI grouped by policy type + platform with search and bulk toggle. +- [x] T009 Mark preview-only types clearly and ensure they never execute. +- [x] T010 Ensure foundations are discoverable (assignment filters, scope tags, notification templates). ## Phase 4 — Safety & Conflict Checks - [ ] T011 Implement `RestoreRiskChecker` (server-side) and persist `check_summary` + `check_results`. @@ -38,8 +38,8 @@ ## Phase 7 — Tests + Formatting - [ ] T018 Add Pest tests for wizard gating rules and status transitions. - [ ] T019 Add Pest tests for safety checks persistence and blocking behavior. - [ ] T020 Add Pest tests for preview summary generation. -- [ ] T021 Run `./vendor/bin/pint --dirty`. -- [ ] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist). +- [x] T021 Run `./vendor/bin/pint --dirty`. +- [x] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist). ## Phase 8 — Policy Version Entry Point (later) - [ ] 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. diff --git a/tests/Feature/Filament/RestoreItemSelectionTest.php b/tests/Feature/Filament/RestoreItemSelectionTest.php index 63ec789..7ac2911 100644 --- a/tests/Feature/Filament/RestoreItemSelectionTest.php +++ b/tests/Feature/Filament/RestoreItemSelectionTest.php @@ -1,5 +1,6 @@ create(['status' => 'active']); $tenant->makeCurrent(); @@ -22,6 +23,13 @@ 'display_name' => 'Policy Display', 'platform' => 'windows', ]); + $previewOnlyPolicy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-preview-only', + 'policy_type' => 'conditionalAccessPolicy', + 'display_name' => 'Conditional Access Policy', + 'platform' => 'all', + ]); $ignoredPolicy = Policy::factory()->create([ 'tenant_id' => $tenant->id, 'external_id' => 'policy-ignored', @@ -32,10 +40,10 @@ ]); $backupSet = BackupSet::factory()->for($tenant)->create([ - 'item_count' => 2, + 'item_count' => 4, ]); - BackupItem::factory() + $policyItem = BackupItem::factory() ->for($tenant) ->for($backupSet) ->state([ @@ -47,7 +55,7 @@ ]) ->create(); - BackupItem::factory() + $ignoredPolicyItem = BackupItem::factory() ->for($tenant) ->for($backupSet) ->state([ @@ -59,7 +67,7 @@ ]) ->create(); - BackupItem::factory() + $scopeTagItem = BackupItem::factory() ->for($tenant) ->for($backupSet) ->state([ @@ -77,6 +85,18 @@ ]) ->create(); + $previewOnlyItem = BackupItem::factory() + ->for($tenant) + ->for($backupSet) + ->state([ + 'policy_id' => $previewOnlyPolicy->id, + 'policy_identifier' => $previewOnlyPolicy->external_id, + 'policy_type' => $previewOnlyPolicy->policy_type, + 'platform' => $previewOnlyPolicy->platform, + 'payload' => ['id' => $previewOnlyPolicy->external_id], + ]) + ->create(); + $user = User::factory()->create(); $this->actingAs($user); @@ -88,13 +108,29 @@ ->fillForm([ 'scope_mode' => 'selected', ]) - ->assertSee('Policy Display') - ->assertDontSee('Ignored Policy') - ->assertSee('Scope Tag Alpha') - ->assertSee('Settings Catalog Policy') - ->assertSee('Scope Tag') - ->assertSee('restore: enabled') - ->assertSee('id: policy-1') - ->assertSee('id: tag-1') + ->assertFormFieldVisible('backup_item_ids') ->assertSee('Include foundations'); + + $method = new ReflectionMethod(RestoreRunResource::class, 'restoreItemGroupedOptions'); + $method->setAccessible(true); + + $groupedOptions = $method->invoke(null, $backupSet->id); + + expect($groupedOptions)->toHaveKey('Configuration • Settings Catalog Policy • windows'); + expect($groupedOptions)->toHaveKey('Foundations • Scope Tag • all'); + expect($groupedOptions)->toHaveKey('Conditional Access • Conditional Access • all • preview-only'); + + $flattenedOptions = collect($groupedOptions) + ->reduce(fn (array $carry, array $options): array => $carry + $options, []); + + expect($flattenedOptions)->toHaveKey($policyItem->id); + expect($flattenedOptions[$policyItem->id])->toBe('Policy Display'); + + expect($flattenedOptions)->not->toHaveKey($ignoredPolicyItem->id); + + expect($flattenedOptions)->toHaveKey($scopeTagItem->id); + expect($flattenedOptions[$scopeTagItem->id])->toBe('Scope Tag Alpha'); + + expect($flattenedOptions)->toHaveKey($previewOnlyItem->id); + expect($flattenedOptions[$previewOnlyItem->id])->toBe('Conditional Access Policy'); }); -- 2.45.2 From f32fdfb1e46f25192e2d67347d5ca31a8788596a Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Tue, 30 Dec 2025 21:49:38 +0100 Subject: [PATCH 05/13] feat: add restore risk checks --- app/Filament/Resources/RestoreRunResource.php | 143 +++- app/Services/Intune/RestoreRiskChecker.php | 608 ++++++++++++++++++ .../components/restore-run-checks.blade.php | 118 ++++ specs/011-restore-run-wizard/tasks.md | 4 +- tests/Feature/RestoreRiskChecksWizardTest.php | 130 ++++ 5 files changed, 995 insertions(+), 8 deletions(-) create mode 100644 app/Services/Intune/RestoreRiskChecker.php create mode 100644 resources/views/filament/forms/components/restore-run-checks.blade.php create mode 100644 tests/Feature/RestoreRiskChecksWizardTest.php diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index b27cdb9..c1a92b3 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -13,6 +13,7 @@ use App\Services\BulkOperationService; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GroupResolver; +use App\Services\Intune\RestoreRiskChecker; use App\Services\Intune\RestoreService; use BackedEnum; use Filament\Actions; @@ -176,6 +177,9 @@ public static function getWizardSteps(): array $set('backup_item_ids', null); $set('group_mapping', []); $set('is_dry_run', true); + $set('check_summary', null); + $set('check_results', []); + $set('checks_ran_at', null); }) ->required(), ]), @@ -192,6 +196,9 @@ public static function getWizardSteps(): array ->reactive() ->afterStateUpdated(function (Set $set, $state): void { $set('group_mapping', []); + $set('check_summary', null); + $set('check_results', []); + $set('checks_ran_at', null); if ($state === 'all') { $set('backup_item_ids', null); @@ -211,7 +218,12 @@ public static function getWizardSteps(): array ->optionsLimit(300) ->options(fn (Get $get) => static::restoreItemGroupedOptions($get('backup_set_id'))) ->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') ->required(fn (Get $get): bool => $get('scope_mode') === 'selected') ->hintActions([ @@ -275,6 +287,12 @@ public static function getWizardSteps(): array ->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); + }) ->helperText('Choose a target group or select Skip.'); }, $unresolved); }) @@ -302,11 +320,98 @@ public static function getWizardSteps(): array }), ]), Step::make('Safety & Conflict Checks') - ->description('Defensive checks (Phase 4)') + ->description('Is this dangerous?') ->schema([ - Forms\Components\Placeholder::make('safety_checks_placeholder') - ->label('Status') - ->content('Safety & conflict checks will be added in Phase 4.'), + 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 = $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') ->description('Dry-run preview (Phase 5)') @@ -858,7 +963,7 @@ public static function createRestoreRun(array $data): RestoreRun ? ($data['backup_item_ids'] ?? null) : null; - return $service->execute( + $restoreRun = $service->execute( tenant: $tenant, backupSet: $backupSet, selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null, @@ -867,6 +972,32 @@ public static function createRestoreRun(array $data): RestoreRun actorName: auth()->user()?->name, 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(); } /** diff --git a/app/Services/Intune/RestoreRiskChecker.php b/app/Services/Intune/RestoreRiskChecker.php new file mode 100644 index 0000000..96ff30c --- /dev/null +++ b/app/Services/Intune/RestoreRiskChecker.php @@ -0,0 +1,608 @@ +|null $selectedItemIds + * @param array $groupMapping + * @return array{summary: array{blocking: int, warning: int, safe: int, has_blockers: bool}, results: array}>} + */ + 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|null $selectedItemIds + * @return Collection + */ + 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 $policyItems + * @param array $groupMapping + * @return array{code: string, severity: string, title: string, message: string, meta: array}|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 $policyItems + * @return array{code: string, severity: string, title: string, message: string, meta: array}|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 $policyItems + * @return array{code: string, severity: string, title: string, message: string, meta: array}|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 $policyItems + * @return array{code: string, severity: string, title: string, message: string, meta: array}|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 $items + * @param Collection $policyItems + * @return array{code: string, severity: string, title: string, message: string, meta: array}|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 $policyItems + * @return array{0: array, 1: array} + */ + 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 + */ + 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> $items + * @return array> + */ + private function truncateList(array $items, int $limit): array + { + if (count($items) <= $limit) { + return $items; + } + + return array_slice($items, 0, $limit); + } +} diff --git a/resources/views/filament/forms/components/restore-run-checks.blade.php b/resources/views/filament/forms/components/restore-run-checks.blade.php new file mode 100644 index 0000000..fe1ff6d --- /dev/null +++ b/resources/views/filament/forms/components/restore-run-checks.blade.php @@ -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 + +
+ +
+ + {{ $blocking }} blocking + + + {{ $warning }} warnings + + + {{ $safe }} safe + +
+
+ + @if ($results === []) + +
+ No checks have been run yet. +
+
+ @else +
+ @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 + + +
+
+
+ {{ $title }} +
+ @if (is_string($message) && $message !== '') +
+ {{ $message }} +
+ @endif +
+ + + {{ ucfirst((string) $severity) }} + +
+ + @if ($unmappedGroups !== []) +
+
+ Unmapped groups +
+
    + @foreach ($unmappedGroups as $group) + @php + $label = is_array($group) ? ($group['label'] ?? $group['id'] ?? null) : null; + @endphp + @if (is_string($label) && $label !== '') +
  • {{ $label }}
  • + @endif + @endforeach +
+
+ @endif +
+ @endforeach +
+ @endif +
+ diff --git a/specs/011-restore-run-wizard/tasks.md b/specs/011-restore-run-wizard/tasks.md index 53f7a83..1e7ed97 100644 --- a/specs/011-restore-run-wizard/tasks.md +++ b/specs/011-restore-run-wizard/tasks.md @@ -22,8 +22,8 @@ ## Phase 3 — Restore Scope UX - [x] T010 Ensure foundations are discoverable (assignment filters, scope tags, notification templates). ## Phase 4 — Safety & Conflict Checks -- [ ] T011 Implement `RestoreRiskChecker` (server-side) and persist `check_summary` + `check_results`. -- [ ] T012 Render check results with severity (blocking/warning/safe) and block execute when blockers exist. +- [x] T011 Implement `RestoreRiskChecker` (server-side) and persist `check_summary` + `check_results`. +- [x] T012 Render check results with severity (blocking/warning/safe) and block execute when blockers exist. ## Phase 5 — Preview (Diff) - [ ] T013 Implement `RestoreDiffGenerator` using `PolicyNormalizer` + `VersionDiff`. diff --git a/tests/Feature/RestoreRiskChecksWizardTest.php b/tests/Feature/RestoreRiskChecksWizardTest.php new file mode 100644 index 0000000..12c98a6 --- /dev/null +++ b/tests/Feature/RestoreRiskChecksWizardTest.php @@ -0,0 +1,130 @@ + '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); +}); -- 2.45.2 From a58db008f85dc7263e686ed867bc1600252ac715 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Tue, 30 Dec 2025 22:05:57 +0100 Subject: [PATCH 06/13] feat: add preview diff step --- app/Filament/Resources/RestoreRunResource.php | 126 ++++++++- app/Services/Intune/RestoreDiffGenerator.php | 248 ++++++++++++++++++ .../components/restore-run-preview.blade.php | 177 +++++++++++++ specs/011-restore-run-wizard/tasks.md | 8 +- tests/Feature/RestoreGroupMappingTest.php | 1 + .../Feature/RestorePreviewDiffWizardTest.php | 132 ++++++++++ tests/Feature/RestoreRiskChecksWizardTest.php | 1 + .../Feature/RestoreRunWizardMetadataTest.php | 2 + 8 files changed, 687 insertions(+), 8 deletions(-) create mode 100644 app/Services/Intune/RestoreDiffGenerator.php create mode 100644 resources/views/filament/forms/components/restore-run-preview.blade.php create mode 100644 tests/Feature/RestorePreviewDiffWizardTest.php diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index c1a92b3..f453c81 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -13,6 +13,7 @@ use App\Services\BulkOperationService; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GroupResolver; +use App\Services\Intune\RestoreDiffGenerator; use App\Services\Intune\RestoreRiskChecker; use App\Services\Intune\RestoreService; use BackedEnum; @@ -180,6 +181,9 @@ public static function getWizardSteps(): array $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(), ]), @@ -199,6 +203,9 @@ public static function getWizardSteps(): array $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); @@ -223,6 +230,9 @@ public static function getWizardSteps(): array $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') @@ -292,6 +302,9 @@ public static function getWizardSteps(): array $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); @@ -414,16 +427,99 @@ public static function getWizardSteps(): array ->helperText('Run checks after defining scope and mapping missing groups.'), ]), Step::make('Preview') - ->description('Dry-run preview (Phase 5)') + ->description('Dry-run preview') ->schema([ Forms\Components\Toggle::make('is_dry_run') ->label('Preview only (dry-run)') ->default(true) ->disabled() ->helperText('Execution will be enabled once checks, preview, and confirmations are implemented (Phase 6).'), - Forms\Components\Placeholder::make('preview_placeholder') + 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') - ->content('Preview diff summary will be added in Phase 5.'), + ->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('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('Explicit confirmations (Phase 6)') @@ -976,8 +1072,18 @@ public static function createRestoreRun(array $data): RestoreRun $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; - if (is_array($checkSummary) || is_array($checkResults) || (is_string($checksRanAt) && $checksRanAt !== '')) { + if ( + is_array($checkSummary) + || is_array($checkResults) + || (is_string($checksRanAt) && $checksRanAt !== '') + || is_array($previewSummary) + || is_array($previewDiffs) + || (is_string($previewRanAt) && $previewRanAt !== '') + ) { $metadata = $restoreRun->metadata ?? []; if (is_array($checkSummary)) { @@ -992,6 +1098,18 @@ public static function createRestoreRun(array $data): RestoreRun $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, ]); diff --git a/app/Services/Intune/RestoreDiffGenerator.php b/app/Services/Intune/RestoreDiffGenerator.php new file mode 100644 index 0000000..200eb65 --- /dev/null +++ b/app/Services/Intune/RestoreDiffGenerator.php @@ -0,0 +1,248 @@ +|null $selectedItemIds + * @return array{summary: array, diffs: array>} + */ + 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|null $selectedItemIds + * @return Collection + */ + 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 $policyIds + * @return array + */ + 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> + */ + 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; + } +} diff --git a/resources/views/filament/forms/components/restore-run-preview.blade.php b/resources/views/filament/forms/components/restore-run-preview.blade.php new file mode 100644 index 0000000..174c6ea --- /dev/null +++ b/resources/views/filament/forms/components/restore-run-preview.blade.php @@ -0,0 +1,177 @@ +@php + $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 + +
+ +
+ + {{ $policiesChanged }}/{{ $policiesTotal }} policies changed + + + {{ $assignmentsChanged }} assignments changed + + + {{ $scopeTagsChanged }} scope tags changed + + @if ($diffsOmitted > 0) + + {{ $diffsOmitted }} diffs omitted (limit) + + @endif +
+
+ + @if ($diffs === []) + +
+ No preview generated yet. +
+
+ @else +
+ @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 + + +
+ + {{ $action }} + + + {{ $added }} added + + + {{ $removed }} removed + + + {{ $changed }} changed + + @if ($assignmentsDelta) + + assignments + + @endif + @if ($scopeTagsDelta) + + scope tags + + @endif + @if ($diffTruncated) + + truncated + + @endif +
+ + @if ($diffOmitted) +
+ Diff details omitted due to preview limits. Narrow scope to see more items in detail. +
+ @elseif ($changedKeys !== [] || $addedKeys !== [] || $removedKeys !== []) +
+ @if ($changedKeys !== []) +
+
+ Changed keys (sample) +
+
    + @foreach ($changedKeys as $key) +
  • + {{ $key }} +
  • + @endforeach +
+
+ @endif + @if ($addedKeys !== []) +
+
+ Added keys (sample) +
+
    + @foreach ($addedKeys as $key) +
  • + {{ $key }} +
  • + @endforeach +
+
+ @endif + @if ($removedKeys !== []) +
+
+ Removed keys (sample) +
+
    + @foreach ($removedKeys as $key) +
  • + {{ $key }} +
  • + @endforeach +
+
+ @endif +
+ @endif +
+ @endforeach +
+ @endif +
+ diff --git a/specs/011-restore-run-wizard/tasks.md b/specs/011-restore-run-wizard/tasks.md index 1e7ed97..338a32a 100644 --- a/specs/011-restore-run-wizard/tasks.md +++ b/specs/011-restore-run-wizard/tasks.md @@ -26,8 +26,8 @@ ## Phase 4 — Safety & Conflict Checks - [x] T012 Render check results with severity (blocking/warning/safe) and block execute when blockers exist. ## Phase 5 — Preview (Diff) -- [ ] T013 Implement `RestoreDiffGenerator` using `PolicyNormalizer` + `VersionDiff`. -- [ ] T014 Persist preview summary (and per-item diffs with safe limits) and require preview completion before execute. +- [x] T013 Implement `RestoreDiffGenerator` using `PolicyNormalizer` + `VersionDiff`. +- [x] T014 Persist preview summary (and per-item diffs with safe limits) and require preview completion before execute. ## Phase 6 — Confirm & Execute - [ ] T015 Implement Step 5 confirmations (ack checkbox + tenant hard-confirm). @@ -36,8 +36,8 @@ ## Phase 6 — Confirm & Execute ## Phase 7 — Tests + Formatting - [ ] T018 Add Pest tests for wizard gating rules and status transitions. -- [ ] T019 Add Pest tests for safety checks persistence and blocking behavior. -- [ ] T020 Add Pest tests for preview summary generation. +- [x] T019 Add Pest tests for safety checks persistence and blocking behavior. +- [x] T020 Add Pest tests for preview summary generation. - [x] T021 Run `./vendor/bin/pint --dirty`. - [x] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist). diff --git a/tests/Feature/RestoreGroupMappingTest.php b/tests/Feature/RestoreGroupMappingTest.php index 260cc99..7b83c99 100644 --- a/tests/Feature/RestoreGroupMappingTest.php +++ b/tests/Feature/RestoreGroupMappingTest.php @@ -168,6 +168,7 @@ ->fillForm([ 'is_dry_run' => true, ]) + ->callFormComponentAction('preview_diffs', 'run_restore_preview') ->goToNextWizardStep() ->call('create') ->assertHasNoFormErrors(); diff --git a/tests/Feature/RestorePreviewDiffWizardTest.php b/tests/Feature/RestorePreviewDiffWizardTest.php new file mode 100644 index 0000000..ab62af1 --- /dev/null +++ b/tests/Feature/RestorePreviewDiffWizardTest.php @@ -0,0 +1,132 @@ + '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); +}); diff --git a/tests/Feature/RestoreRiskChecksWizardTest.php b/tests/Feature/RestoreRiskChecksWizardTest.php index 12c98a6..79e90e1 100644 --- a/tests/Feature/RestoreRiskChecksWizardTest.php +++ b/tests/Feature/RestoreRiskChecksWizardTest.php @@ -114,6 +114,7 @@ $component ->goToNextWizardStep() + ->callFormComponentAction('preview_diffs', 'run_restore_preview') ->goToNextWizardStep() ->call('create') ->assertHasNoFormErrors(); diff --git a/tests/Feature/RestoreRunWizardMetadataTest.php b/tests/Feature/RestoreRunWizardMetadataTest.php index b8f13e1..7e8469c 100644 --- a/tests/Feature/RestoreRunWizardMetadataTest.php +++ b/tests/Feature/RestoreRunWizardMetadataTest.php @@ -65,6 +65,7 @@ ->fillForm([ 'is_dry_run' => true, ]) + ->callFormComponentAction('preview_diffs', 'run_restore_preview') ->goToNextWizardStep() ->call('create') ->assertHasNoFormErrors(); @@ -127,6 +128,7 @@ ->goToNextWizardStep() ->goToNextWizardStep() ->set('data.is_dry_run', false) + ->callFormComponentAction('preview_diffs', 'run_restore_preview') ->goToNextWizardStep() ->call('create') ->assertHasNoFormErrors(); -- 2.45.2 From 5e16c25fca92e690d6a97e1762c7c975f849bba8 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Tue, 30 Dec 2025 23:38:05 +0100 Subject: [PATCH 07/13] fix: show check/preview actions --- .../components/restore-run-checks.blade.php | 147 +++++----- .../components/restore-run-preview.blade.php | 259 +++++++++--------- 2 files changed, 206 insertions(+), 200 deletions(-) diff --git a/resources/views/filament/forms/components/restore-run-checks.blade.php b/resources/views/filament/forms/components/restore-run-checks.blade.php index fe1ff6d..e7469ba 100644 --- a/resources/views/filament/forms/components/restore-run-checks.blade.php +++ b/resources/views/filament/forms/components/restore-run-checks.blade.php @@ -1,4 +1,6 @@ @php + $fieldWrapperView = $getFieldWrapperView(); + $results = $getState() ?? []; $results = is_array($results) ? $results : []; @@ -37,82 +39,83 @@ }; @endphp -
- -
- - {{ $blocking }} blocking - - - {{ $warning }} warnings - - - {{ $safe }} safe - -
-
- - @if ($results === []) - -
- No checks have been run yet. + +
+ +
+ + {{ $blocking }} blocking + + + {{ $warning }} warnings + + + {{ $safe }} safe +
- @else -
- @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 + @if ($results === []) + +
+ No checks have been run yet. +
+
+ @else +
+ @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 : []; - -
-
-
- {{ $title }} -
- @if (is_string($message) && $message !== '') -
- {{ $message }} + $unmappedGroups = $meta['unmapped'] ?? []; + $unmappedGroups = is_array($unmappedGroups) ? $limitedList($unmappedGroups) : []; + @endphp + + +
+
+
+ {{ $title }}
- @endif -
- - - {{ ucfirst((string) $severity) }} - -
- - @if ($unmappedGroups !== []) -
-
- Unmapped groups + @if (is_string($message) && $message !== '') +
+ {{ $message }} +
+ @endif
-
    - @foreach ($unmappedGroups as $group) - @php - $label = is_array($group) ? ($group['label'] ?? $group['id'] ?? null) : null; - @endphp - @if (is_string($label) && $label !== '') -
  • {{ $label }}
  • - @endif - @endforeach -
-
- @endif -
- @endforeach -
- @endif -
+ + {{ ucfirst((string) $severity) }} + +
+ + @if ($unmappedGroups !== []) +
+
+ Unmapped groups +
+
    + @foreach ($unmappedGroups as $group) + @php + $label = is_array($group) ? ($group['label'] ?? $group['id'] ?? null) : null; + @endphp + @if (is_string($label) && $label !== '') +
  • {{ $label }}
  • + @endif + @endforeach +
+
+ @endif +
+ @endforeach +
+ @endif +
+ diff --git a/resources/views/filament/forms/components/restore-run-preview.blade.php b/resources/views/filament/forms/components/restore-run-preview.blade.php index 174c6ea..f92e4b1 100644 --- a/resources/views/filament/forms/components/restore-run-preview.blade.php +++ b/resources/views/filament/forms/components/restore-run-preview.blade.php @@ -1,4 +1,6 @@ @php + $fieldWrapperView = $getFieldWrapperView(); + $diffs = $getState() ?? []; $diffs = is_array($diffs) ? $diffs : []; @@ -33,145 +35,146 @@ }; @endphp -
- -
- - {{ $policiesChanged }}/{{ $policiesTotal }} policies changed - - - {{ $assignmentsChanged }} assignments changed - - - {{ $scopeTagsChanged }} scope tags changed - - @if ($diffsOmitted > 0) - - {{ $diffsOmitted }} diffs omitted (limit) + +
+ +
+ + {{ $policiesChanged }}/{{ $policiesTotal }} policies changed - @endif -
-
- - @if ($diffs === []) - -
- No preview generated yet. + + {{ $assignmentsChanged }} assignments changed + + + {{ $scopeTagsChanged }} scope tags changed + + @if ($diffsOmitted > 0) + + {{ $diffsOmitted }} diffs omitted (limit) + + @endif
- @else -
- @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); + @if ($diffs === []) + +
+ No preview generated yet. +
+
+ @else +
+ @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'] : []; - $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); + $added = (int) ($diffSummary['added'] ?? 0); + $removed = (int) ($diffSummary['removed'] ?? 0); + $changed = (int) ($diffSummary['changed'] ?? 0); - $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 + $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); - -
- - {{ $action }} - - - {{ $added }} added - - - {{ $removed }} removed - - - {{ $changed }} changed - - @if ($assignmentsDelta) + $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 + + +
+ + {{ $action }} + + + {{ $added }} added + + + {{ $removed }} removed + - assignments + {{ $changed }} changed - @endif - @if ($scopeTagsDelta) - - scope tags - - @endif - @if ($diffTruncated) - - truncated - - @endif -
- - @if ($diffOmitted) -
- Diff details omitted due to preview limits. Narrow scope to see more items in detail. -
- @elseif ($changedKeys !== [] || $addedKeys !== [] || $removedKeys !== []) -
- @if ($changedKeys !== []) -
-
- Changed keys (sample) -
-
    - @foreach ($changedKeys as $key) -
  • - {{ $key }} -
  • - @endforeach -
-
+ @if ($assignmentsDelta) + + assignments + @endif - @if ($addedKeys !== []) -
-
- Added keys (sample) -
-
    - @foreach ($addedKeys as $key) -
  • - {{ $key }} -
  • - @endforeach -
-
+ @if ($scopeTagsDelta) + + scope tags + @endif - @if ($removedKeys !== []) -
-
- Removed keys (sample) -
-
    - @foreach ($removedKeys as $key) -
  • - {{ $key }} -
  • - @endforeach -
-
+ @if ($diffTruncated) + + truncated + @endif
- @endif -
- @endforeach -
- @endif -
+ @if ($diffOmitted) +
+ Diff details omitted due to preview limits. Narrow scope to see more items in detail. +
+ @elseif ($changedKeys !== [] || $addedKeys !== [] || $removedKeys !== []) +
+ @if ($changedKeys !== []) +
+
+ Changed keys (sample) +
+
    + @foreach ($changedKeys as $key) +
  • + {{ $key }} +
  • + @endforeach +
+
+ @endif + @if ($addedKeys !== []) +
+
+ Added keys (sample) +
+
    + @foreach ($addedKeys as $key) +
  • + {{ $key }} +
  • + @endforeach +
+
+ @endif + @if ($removedKeys !== []) +
+
+ Removed keys (sample) +
+
    + @foreach ($removedKeys as $key) +
  • + {{ $key }} +
  • + @endforeach +
+
+ @endif +
+ @endif + + @endforeach +
+ @endif +
+
-- 2.45.2 From 26755df017871ac107975e610dfd939a4a092c2e Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 31 Dec 2025 01:50:33 +0100 Subject: [PATCH 08/13] feat: add confirm & queued execute to restore wizard --- app/Filament/Resources/RestoreRunResource.php | 248 +++++++++++++++--- .../Pages/CreateRestoreRun.php | 4 +- app/Jobs/ExecuteRestoreRunJob.php | 134 ++++++++++ app/Services/Intune/RestoreService.php | 92 ++++++- specs/011-restore-run-wizard/tasks.md | 8 +- tests/Feature/ExecuteRestoreRunJobTest.php | 68 +++++ tests/Feature/RestoreGroupMappingTest.php | 3 - tests/Feature/RestoreRunWizardExecuteTest.php | 165 ++++++++++++ .../Feature/RestoreRunWizardMetadataTest.php | 55 ---- 9 files changed, 669 insertions(+), 108 deletions(-) create mode 100644 app/Jobs/ExecuteRestoreRunJob.php create mode 100644 tests/Feature/ExecuteRestoreRunJobTest.php create mode 100644 tests/Feature/RestoreRunWizardExecuteTest.php diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index f453c81..93093e5 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -6,6 +6,7 @@ use App\Jobs\BulkRestoreRunDeleteJob; use App\Jobs\BulkRestoreRunForceDeleteJob; use App\Jobs\BulkRestoreRunRestoreJob; +use App\Jobs\ExecuteRestoreRunJob; use App\Models\BackupItem; use App\Models\BackupSet; use App\Models\RestoreRun; @@ -13,9 +14,11 @@ use App\Services\BulkOperationService; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GroupResolver; +use App\Services\Intune\AuditLogger; use App\Services\Intune\RestoreDiffGenerator; use App\Services\Intune\RestoreRiskChecker; use App\Services\Intune\RestoreService; +use App\Support\RestoreRunStatus; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; @@ -37,6 +40,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; use UnitEnum; class RestoreRunResource extends Resource @@ -178,6 +182,8 @@ public static function getWizardSteps(): array $set('backup_item_ids', null); $set('group_mapping', []); $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); @@ -200,6 +206,9 @@ public static function getWizardSteps(): array ->reactive() ->afterStateUpdated(function (Set $set, $state): void { $set('group_mapping', []); + $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); @@ -227,6 +236,9 @@ public static function getWizardSteps(): array ->reactive() ->afterStateUpdated(function (Set $set): void { $set('group_mapping', []); + $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); @@ -407,6 +419,10 @@ public static function getWizardSteps(): array $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}") @@ -419,6 +435,9 @@ public static function getWizardSteps(): array ->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); @@ -429,11 +448,6 @@ public static function getWizardSteps(): array Step::make('Preview') ->description('Dry-run preview') ->schema([ - Forms\Components\Toggle::make('is_dry_run') - ->label('Preview only (dry-run)') - ->default(true) - ->disabled() - ->helperText('Execution will be enabled once checks, preview, and confirmations are implemented (Phase 6).'), Forms\Components\Hidden::make('preview_summary') ->default(null), Forms\Components\Hidden::make('preview_ran_at') @@ -514,6 +528,9 @@ public static function getWizardSteps(): array ->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); @@ -522,11 +539,76 @@ public static function getWizardSteps(): array ->helperText('Generate a normalized diff preview before creating the dry-run restore.'), ]), Step::make('Confirm & Execute') - ->description('Explicit confirmations (Phase 6)') + ->description('Point of no return') ->schema([ - Forms\Components\Placeholder::make('confirm_placeholder') - ->label('Execution') - ->content('Execution confirmations and gating will be added in Phase 6.'), + 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}"; + }), ]), ]; } @@ -1055,19 +1137,13 @@ public static function createRestoreRun(array $data): RestoreRun $service = app(RestoreService::class); $scopeMode = $data['scope_mode'] ?? 'all'; - $selectedItemIds = ($scopeMode === 'selected') - ? ($data['backup_item_ids'] ?? null) - : null; + $selectedItemIds = ($scopeMode === 'selected') ? ($data['backup_item_ids'] ?? null) : null; + $selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null; - $restoreRun = $service->execute( - tenant: $tenant, - backupSet: $backupSet, - selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null, - dryRun: true, - actorEmail: auth()->user()?->email, - actorName: auth()->user()?->name, - groupMapping: $data['group_mapping'] ?? [], - ); + $actorEmail = auth()->user()?->email; + $actorName = auth()->user()?->name; + $isDryRun = (bool) ($data['is_dry_run'] ?? true); + $groupMapping = $data['group_mapping'] ?? []; $checkSummary = $data['check_summary'] ?? null; $checkResults = $data['check_results'] ?? null; @@ -1076,14 +1152,57 @@ public static function createRestoreRun(array $data): RestoreRun $previewDiffs = $data['preview_diffs'] ?? null; $previewRanAt = $data['preview_ran_at'] ?? null; - if ( - is_array($checkSummary) - || is_array($checkResults) - || (is_string($checksRanAt) && $checksRanAt !== '') - || is_array($previewSummary) - || is_array($previewDiffs) - || (is_string($previewRanAt) && $previewRanAt !== '') - ) { + $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)) { @@ -1110,11 +1229,76 @@ public static function createRestoreRun(array $data): RestoreRun $metadata['preview_ran_at'] = $previewRanAt; } - $restoreRun->update([ - 'metadata' => $metadata, - ]); + $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, + action: 'restore.queued', + context: [ + 'metadata' => [ + 'restore_run_id' => $restoreRun->id, + 'backup_set_id' => $backupSet->id, + ], + ], + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'restore_run', + resourceId: (string) $restoreRun->id, + status: 'success', + ); + + ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName); + return $restoreRun->refresh(); } diff --git a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php index 0fff9d4..d46d355 100644 --- a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php +++ b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php @@ -22,8 +22,8 @@ public function getSteps(): array protected function getSubmitFormAction(): Action { return parent::getSubmitFormAction() - ->label('Create preview (dry-run)') - ->icon('heroicon-o-eye'); + ->label('Create restore run') + ->icon('heroicon-o-check-circle'); } protected function handleRecordCreation(array $data): Model diff --git a/app/Jobs/ExecuteRestoreRunJob.php b/app/Jobs/ExecuteRestoreRunJob.php new file mode 100644 index 0000000..dd57d20 --- /dev/null +++ b/app/Jobs/ExecuteRestoreRunJob.php @@ -0,0 +1,134 @@ +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; + } + } +} diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index 2b8aaef..e9e02d1 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -184,6 +184,45 @@ public function executeFromPolicyVersion( ); } + public function executeForRun( + RestoreRun $restoreRun, + Tenant $tenant, + BackupSet $backupSet, + ?string $actorEmail = null, + ?string $actorName = null, + ): RestoreRun { + $this->assertActiveContext($tenant, $backupSet); + + if ($restoreRun->tenant_id !== $tenant->id) { + throw new \InvalidArgumentException('Restore run does not belong to the provided tenant.'); + } + + if ($restoreRun->backup_set_id !== $backupSet->id) { + throw new \InvalidArgumentException('Restore run does not belong to the provided backup set.'); + } + + if (in_array($restoreRun->status, ['completed', 'partial', 'failed', 'cancelled'], true)) { + throw new \RuntimeException('Restore run is already finished.'); + } + + $selectedItemIds = is_array($restoreRun->requested_items) ? $restoreRun->requested_items : null; + + if ($selectedItemIds === []) { + $selectedItemIds = null; + } + + return $this->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: $selectedItemIds, + dryRun: (bool) $restoreRun->is_dry_run, + actorEmail: $actorEmail, + actorName: $actorName, + groupMapping: $restoreRun->group_mapping ?? [], + existingRun: $restoreRun, + ); + } + public function execute( Tenant $tenant, BackupSet $backupSet, @@ -192,6 +231,7 @@ public function execute( ?string $actorEmail = null, ?string $actorName = null, array $groupMapping = [], + ?RestoreRun $existingRun = null, ): RestoreRun { $this->assertActiveContext($tenant, $backupSet); @@ -210,18 +250,46 @@ public function execute( 'highlander_label' => (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()), ]; - $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 ($existingRun !== null) { + if ($existingRun->tenant_id !== $tenant->id) { + throw new \InvalidArgumentException('Restore run does not belong to the provided tenant.'); + } + + if ($existingRun->backup_set_id !== $backupSet->id) { + throw new \InvalidArgumentException('Restore run does not belong to the provided backup set.'); + } + + $metadata = array_merge($wizardMetadata, $existingRun->metadata ?? []); + + $existingRun->update([ + 'requested_by' => $existingRun->requested_by ?? $actorEmail, + 'is_dry_run' => $dryRun, + 'status' => 'running', + 'requested_items' => $selectedItemIds, + 'preview' => $preview, + 'results' => null, + 'failure_reason' => null, + 'started_at' => $existingRun->started_at ?? CarbonImmutable::now(), + 'completed_at' => null, + 'metadata' => $metadata, + 'group_mapping' => $groupMapping !== [] ? $groupMapping : ($existingRun->group_mapping ?? null), + ]); + + $restoreRun = $existingRun->refresh(); + } else { + $restoreRun = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'requested_by' => $actorEmail, + 'is_dry_run' => $dryRun, + 'status' => 'running', + 'requested_items' => $selectedItemIds, + 'preview' => $preview, + 'started_at' => CarbonImmutable::now(), + 'metadata' => $wizardMetadata, + 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, + ]); + } if ($groupMapping !== []) { $this->auditLogger->log( diff --git a/specs/011-restore-run-wizard/tasks.md b/specs/011-restore-run-wizard/tasks.md index 338a32a..be82696 100644 --- a/specs/011-restore-run-wizard/tasks.md +++ b/specs/011-restore-run-wizard/tasks.md @@ -30,12 +30,12 @@ ## Phase 5 — Preview (Diff) - [x] T014 Persist preview summary (and per-item diffs with safe limits) and require preview completion before execute. ## Phase 6 — Confirm & Execute -- [ ] T015 Implement Step 5 confirmations (ack checkbox + tenant hard-confirm). -- [ ] T016 Execute restore via a queued Job (preferred) and update statuses + timestamps. -- [ ] T017 Persist execution outcomes and ensure audit logging entries exist for execution start/finish. +- [x] T015 Implement Step 5 confirmations (ack checkbox + tenant hard-confirm). +- [x] T016 Execute restore via a queued Job (preferred) and update statuses + timestamps. +- [x] T017 Persist execution outcomes and ensure audit logging entries exist for execution start/finish. ## Phase 7 — Tests + Formatting -- [ ] T018 Add Pest tests for wizard gating rules and status transitions. +- [x] T018 Add Pest tests for wizard gating rules and status transitions. - [x] T019 Add Pest tests for safety checks persistence and blocking behavior. - [x] T020 Add Pest tests for preview summary generation. - [x] T021 Run `./vendor/bin/pint --dirty`. diff --git a/tests/Feature/ExecuteRestoreRunJobTest.php b/tests/Feature/ExecuteRestoreRunJobTest.php new file mode 100644 index 0000000..9fb258a --- /dev/null +++ b/tests/Feature/ExecuteRestoreRunJobTest.php @@ -0,0 +1,68 @@ + '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); +}); diff --git a/tests/Feature/RestoreGroupMappingTest.php b/tests/Feature/RestoreGroupMappingTest.php index 7b83c99..de3b9cc 100644 --- a/tests/Feature/RestoreGroupMappingTest.php +++ b/tests/Feature/RestoreGroupMappingTest.php @@ -165,9 +165,6 @@ ]) ->goToNextWizardStep() ->goToNextWizardStep() - ->fillForm([ - 'is_dry_run' => true, - ]) ->callFormComponentAction('preview_diffs', 'run_restore_preview') ->goToNextWizardStep() ->call('create') diff --git a/tests/Feature/RestoreRunWizardExecuteTest.php b/tests/Feature/RestoreRunWizardExecuteTest.php new file mode 100644 index 0000000..4332917 --- /dev/null +++ b/tests/Feature/RestoreRunWizardExecuteTest.php @@ -0,0 +1,165 @@ + '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); +}); diff --git a/tests/Feature/RestoreRunWizardMetadataTest.php b/tests/Feature/RestoreRunWizardMetadataTest.php index 7e8469c..10c9697 100644 --- a/tests/Feature/RestoreRunWizardMetadataTest.php +++ b/tests/Feature/RestoreRunWizardMetadataTest.php @@ -62,9 +62,6 @@ ]) ->goToNextWizardStep() ->goToNextWizardStep() - ->fillForm([ - 'is_dry_run' => true, - ]) ->callFormComponentAction('preview_diffs', 'run_restore_preview') ->goToNextWizardStep() ->call('create') @@ -87,55 +84,3 @@ expect($run->metadata['environment'])->toBe('test'); expect($run->metadata['highlander_label'])->toBe('Tenant One'); }); - -test('restore run wizard always creates dry-run previews in phase 2', function () { - $tenant = Tenant::create([ - 'tenant_id' => 'tenant-2', - 'name' => 'Tenant Two', - 'metadata' => [], - ]); - - $tenant->makeCurrent(); - - $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' => null, - 'policy_identifier' => 'policy-2', - 'policy_type' => 'deviceConfiguration', - 'platform' => 'windows', - 'payload' => ['id' => 'policy-2'], - 'metadata' => [ - 'displayName' => 'Backup Policy Two', - ], - ]); - - $user = User::factory()->create(); - $this->actingAs($user); - - Livewire::test(CreateRestoreRun::class) - ->fillForm([ - 'backup_set_id' => $backupSet->id, - ]) - ->goToNextWizardStep() - ->goToNextWizardStep() - ->goToNextWizardStep() - ->set('data.is_dry_run', false) - ->callFormComponentAction('preview_diffs', 'run_restore_preview') - ->goToNextWizardStep() - ->call('create') - ->assertHasNoFormErrors(); - - $run = RestoreRun::query()->latest('id')->first(); - - expect($run)->not->toBeNull(); - expect($run->is_dry_run)->toBeTrue(); - expect($run->status)->toBe('previewed'); -}); -- 2.45.2 From 9e3c2b30115f69dcf053a7278932bcdd752f4bc6 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 31 Dec 2025 11:53:03 +0100 Subject: [PATCH 09/13] fix: accept object group mapping in checks --- app/Filament/Resources/RestoreRunResource.php | 78 ++++++++++++++-- tests/Feature/RestoreRiskChecksWizardTest.php | 91 +++++++++++++++++++ 2 files changed, 163 insertions(+), 6 deletions(-) diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 93093e5..8959cc7 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -397,11 +397,7 @@ public static function getWizardSteps(): array $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(); + $groupMapping = static::normalizeGroupMapping($get('group_mapping')); $checker = app(RestoreRiskChecker::class); $outcome = $checker->check( @@ -1143,7 +1139,7 @@ public static function createRestoreRun(array $data): RestoreRun $actorEmail = auth()->user()?->email; $actorName = auth()->user()?->name; $isDryRun = (bool) ($data['is_dry_run'] ?? true); - $groupMapping = $data['group_mapping'] ?? []; + $groupMapping = static::normalizeGroupMapping($data['group_mapping'] ?? null); $checkSummary = $data['check_summary'] ?? null; $checkResults = $data['check_results'] ?? null; @@ -1386,6 +1382,76 @@ private static function unresolvedGroups(?int $backupSetId, ?array $selectedItem return $unresolved; } + /** + * @return array + */ + 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 $result; + } + /** * @return array */ diff --git a/tests/Feature/RestoreRiskChecksWizardTest.php b/tests/Feature/RestoreRiskChecksWizardTest.php index 79e90e1..c878d84 100644 --- a/tests/Feature/RestoreRiskChecksWizardTest.php +++ b/tests/Feature/RestoreRiskChecksWizardTest.php @@ -129,3 +129,94 @@ ]); 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'); +}); -- 2.45.2 From 44b4a6adf0ae8ff47223007b4d3f8887558644ef Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 31 Dec 2025 13:27:50 +0100 Subject: [PATCH 10/13] fix: prime group mapping state --- app/Filament/Resources/RestoreRunResource.php | 112 +++++++++++++----- tests/Feature/RestoreGroupMappingTest.php | 13 +- 2 files changed, 95 insertions(+), 30 deletions(-) diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 8959cc7..ff2500b 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -177,10 +177,15 @@ public static function getWizardSteps(): array }); }) ->reactive() - ->afterStateUpdated(function (Set $set): void { + ->afterStateUpdated(function (Set $set, Get $get): void { $set('scope_mode', 'all'); $set('backup_item_ids', null); - $set('group_mapping', []); + $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); @@ -204,8 +209,9 @@ public static function getWizardSteps(): array ]) ->default('all') ->reactive() - ->afterStateUpdated(function (Set $set, $state): void { - $set('group_mapping', []); + ->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); @@ -218,10 +224,17 @@ public static function getWizardSteps(): array 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(), @@ -234,8 +247,18 @@ public static function getWizardSteps(): array ->optionsLimit(300) ->options(fn (Get $get) => static::restoreItemGroupedOptions($get('backup_set_id'))) ->reactive() - ->afterStateUpdated(function (Set $set): void { - $set('group_mapping', []); + ->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); @@ -249,29 +272,29 @@ public static function getWizardSteps(): array ->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')); + 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 = []; + $allItemIds = []; - foreach ($groupedOptions as $options) { - $allItemIds = array_merge($allItemIds, array_keys($options)); - } + 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)), - ]) + $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.') @@ -1383,8 +1406,43 @@ private static function unresolvedGroups(?int $backupSetId, ?array $selectedItem } /** + * @param array|null $selectedItemIds * @return array */ + 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 + */ private static function normalizeGroupMapping(mixed $mapping): array { if ($mapping instanceof \Illuminate\Contracts\Support\Arrayable) { @@ -1449,7 +1507,7 @@ private static function normalizeGroupMapping(mixed $mapping): array $result[$sourceGroupId] = null; } - return $result; + return array_filter($result, static fn (?string $value): bool => is_string($value) && $value !== ''); } /** diff --git a/tests/Feature/RestoreGroupMappingTest.php b/tests/Feature/RestoreGroupMappingTest.php index de3b9cc..5746b5b 100644 --- a/tests/Feature/RestoreGroupMappingTest.php +++ b/tests/Feature/RestoreGroupMappingTest.php @@ -77,7 +77,7 @@ $user = User::factory()->create(); $this->actingAs($user); - Livewire::test(CreateRestoreRun::class) + $component = Livewire::test(CreateRestoreRun::class) ->fillForm([ 'backup_set_id' => $backupSet->id, ]) @@ -85,8 +85,15 @@ ->fillForm([ 'scope_mode' => 'selected', 'backup_item_ids' => [$backupItem->id], - ]) - ->assertFormFieldVisible('group_mapping.source-group-1'); + ]); + + $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 () { -- 2.45.2 From e19aa09ae0ef5675196ba4c75dc6192c5058a499 Mon Sep 17 00:00:00 2001 From: ahmido Date: Wed, 31 Dec 2025 19:02:28 +0000 Subject: [PATCH 11/13] feat(wizard): Add restore from policy version (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the "Restore via Wizard" action on the PolicyVersion resource. This allows a user to initiate a restore run directly from a specific policy version snapshot. - Adds a "Restore via Wizard" action to the PolicyVersion table. - This action creates a single-item BackupSet from the selected version. - The CreateRestoreRun wizard is now pre-filled from query parameters. - Adds feature tests to cover the new workflow. - Updates tasks.md to reflect the completed work. ## Summary ## Spec-Driven Development (SDD) - [ ] Es gibt eine Spec unter `specs/-/` - [ ] Enthaltene Dateien: `plan.md`, `tasks.md`, `spec.md` - [ ] Spec beschreibt Verhalten/Acceptance Criteria (nicht nur Implementation) - [ ] Wenn sich Anforderungen während der Umsetzung geändert haben: Spec/Plan/Tasks wurden aktualisiert ## Implementation - [ ] Implementierung entspricht der Spec - [ ] Edge cases / Fehlerfälle berücksichtigt - [ ] Keine unbeabsichtigten Änderungen außerhalb des Scopes ## Tests - [ ] Tests ergänzt/aktualisiert (Pest/PHPUnit) - [ ] Relevante Tests lokal ausgeführt (`./vendor/bin/sail artisan test` oder `php artisan test`) ## Migration / Config / Ops (falls relevant) - [ ] Migration(en) enthalten und getestet - [ ] Rollback bedacht (rückwärts kompatibel, sichere Migration) - [ ] Neue Env Vars dokumentiert (`.env.example` / Doku) - [ ] Queue/cron/storage Auswirkungen geprüft ## UI (Filament/Livewire) (falls relevant) - [ ] UI-Flows geprüft - [ ] Screenshots/Notizen hinzugefügt ## Notes Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/15 --- .../Resources/PolicyVersionResource.php | 93 ++++++++++ .../Pages/CreateRestoreRun.php | 89 ++++++++++ specs/011-restore-run-wizard/tasks.md | 2 +- .../PolicyVersionRestoreViaWizardTest.php | 164 ++++++++++++++++++ 4 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index bb3e19d..4bab649 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -6,6 +6,8 @@ use App\Jobs\BulkPolicyVersionForceDeleteJob; use App\Jobs\BulkPolicyVersionPruneJob; use App\Jobs\BulkPolicyVersionRestoreJob; +use App\Models\BackupItem; +use App\Models\BackupSet; use App\Models\PolicyVersion; use App\Models\Tenant; use App\Services\BulkOperationService; @@ -13,6 +15,7 @@ use App\Services\Intune\PolicyNormalizer; use App\Services\Intune\VersionDiff; use BackedEnum; +use Carbon\CarbonImmutable; use Filament\Actions; use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; @@ -183,6 +186,96 @@ public static function table(Table $table): Table ->url(fn (PolicyVersion $record) => static::getUrl('view', ['record' => $record])) ->openUrlInNewTab(false), Actions\ActionGroup::make([ + Actions\Action::make('restore_via_wizard') + ->label('Restore via Wizard') + ->icon('heroicon-o-arrow-path-rounded-square') + ->color('primary') + ->requiresConfirmation() + ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?") + ->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.') + ->action(function (PolicyVersion $record) { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant || $record->tenant_id !== $tenant->id) { + Notification::make() + ->title('Policy version belongs to a different tenant') + ->danger() + ->send(); + + return; + } + + $policy = $record->policy; + + if (! $policy) { + Notification::make() + ->title('Policy could not be found for this version') + ->danger() + ->send(); + + return; + } + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => sprintf( + 'Policy Version Restore • %s • v%d', + $policy->display_name, + $record->version_number + ), + 'created_by' => $user?->email, + 'status' => 'completed', + 'item_count' => 1, + 'completed_at' => CarbonImmutable::now(), + 'metadata' => [ + 'source' => 'policy_version', + 'policy_version_id' => $record->id, + 'policy_version_number' => $record->version_number, + 'policy_id' => $policy->id, + ], + ]); + + $scopeTags = is_array($record->scope_tags) ? $record->scope_tags : []; + $scopeTagIds = $scopeTags['ids'] ?? null; + $scopeTagNames = $scopeTags['names'] ?? null; + + $backupItemMetadata = [ + 'source' => 'policy_version', + 'display_name' => $policy->display_name, + 'policy_version_id' => $record->id, + 'policy_version_number' => $record->version_number, + 'version_captured_at' => $record->captured_at?->toIso8601String(), + ]; + + if (is_array($scopeTagIds) && $scopeTagIds !== []) { + $backupItemMetadata['scope_tag_ids'] = $scopeTagIds; + } + + if (is_array($scopeTagNames) && $scopeTagNames !== []) { + $backupItemMetadata['scope_tag_names'] = $scopeTagNames; + } + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_version_id' => $record->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => $record->captured_at ?? CarbonImmutable::now(), + 'payload' => $record->snapshot ?? [], + 'metadata' => $backupItemMetadata, + 'assignments' => $record->assignments, + ]); + + return redirect()->to(RestoreRunResource::getUrl('create', [ + 'backup_set_id' => $backupSet->id, + 'scope_mode' => 'selected', + 'backup_item_ids' => [$backupItem->id], + ])); + }), Actions\Action::make('archive') ->label('Archive') ->color('danger') diff --git a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php index d46d355..2e4e6de 100644 --- a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php +++ b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php @@ -3,6 +3,8 @@ namespace App\Filament\Resources\RestoreRunResource\Pages; use App\Filament\Resources\RestoreRunResource; +use App\Models\BackupSet; +use App\Models\Tenant; use Filament\Actions\Action; use Filament\Resources\Pages\Concerns\HasWizard; use Filament\Resources\Pages\CreateRecord; @@ -19,6 +21,93 @@ 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 + */ + 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() diff --git a/specs/011-restore-run-wizard/tasks.md b/specs/011-restore-run-wizard/tasks.md index be82696..39cbde8 100644 --- a/specs/011-restore-run-wizard/tasks.md +++ b/specs/011-restore-run-wizard/tasks.md @@ -42,4 +42,4 @@ ## Phase 7 — Tests + Formatting - [x] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist). ## Phase 8 — Policy Version Entry Point (later) -- [ ] 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. +- [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. diff --git a/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php b/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php new file mode 100644 index 0000000..7a84965 --- /dev/null +++ b/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php @@ -0,0 +1,164 @@ + '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'); +}); -- 2.45.2 From 3e7e699e00a7e2589c55a16da1db88f4614e4329 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 31 Dec 2025 21:01:29 +0100 Subject: [PATCH 12/13] docs(spec): Add spec for 012-windows-update-rings --- specs/012-windows-update-rings/plan.md | 15 +++++++++ specs/012-windows-update-rings/spec.md | 45 +++++++++++++++++++++++++ specs/012-windows-update-rings/tasks.md | 23 +++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 specs/012-windows-update-rings/plan.md create mode 100644 specs/012-windows-update-rings/spec.md create mode 100644 specs/012-windows-update-rings/tasks.md diff --git a/specs/012-windows-update-rings/plan.md b/specs/012-windows-update-rings/plan.md new file mode 100644 index 0000000..5520cb5 --- /dev/null +++ b/specs/012-windows-update-rings/plan.md @@ -0,0 +1,15 @@ +# 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. + +## 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. **Tests + formatting**: Add targeted Pest tests for snapshot hydration, normalized display, and restore functionality. Run `./vendor/bin/pint --dirty` and the affected tests. diff --git a/specs/012-windows-update-rings/spec.md b/specs/012-windows-update-rings/spec.md new file mode 100644 index 0000000..5df781e --- /dev/null +++ b/specs/012-windows-update-rings/spec.md @@ -0,0 +1,45 @@ +# 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 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. + +## 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. diff --git a/specs/012-windows-update-rings/tasks.md b/specs/012-windows-update-rings/tasks.md new file mode 100644 index 0000000..96272e9 --- /dev/null +++ b/specs/012-windows-update-rings/tasks.md @@ -0,0 +1,23 @@ +# 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 +- [ ] T001 Verify `config/graph_contracts.php` for `windowsUpdateRing` (resource, allowed_select, type_family, etc.). +- [ ] T002 Extend `PolicySnapshotService` to hydrate `windowsUpdateForBusinessConfiguration` settings. + +## Phase 2: Restore +- [ ] T003 Implement restore apply for `windowsUpdateRing` settings in `RestoreService.php`. + +## Phase 3: UI Normalization +- [ ] T004 Add `WindowsUpdateRingNormalizer` and register it (Policy “Normalized settings” is readable). + +## Phase 4: Tests + Verification +- [ ] T005 Add tests for hydration + UI display. +- [ ] T006 Add tests for restore apply. +- [ ] T007 Run tests (targeted). +- [ ] T008 Run Pint (`./vendor/bin/pint --dirty`). + +## Open TODOs (Follow-up) +- None yet. -- 2.45.2 From 28b120c5797d6f73c18e1a63f0e9b89b2c2347e4 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 7 Jan 2026 02:28:39 +0100 Subject: [PATCH 13/13] fix(graph): accept derived odata types --- config/graph_contracts.php | 7 +++++ specs/011-restore-run-wizard/tasks.md | 1 + tests/Pest.php | 3 ++ tests/Unit/ODataTypeValidationTest.php | 38 ++++++++++++++++++++++++++ 4 files changed, 49 insertions(+) create mode 100644 tests/Unit/ODataTypeValidationTest.php diff --git a/config/graph_contracts.php b/config/graph_contracts.php index 6c6ac67..856eb14 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -19,6 +19,8 @@ 'type_family' => [ '#microsoft.graph.deviceConfiguration', '#microsoft.graph.windows10CustomConfiguration', + '#microsoft.graph.windows10GeneralConfiguration', + '#microsoft.graph.windowsHealthMonitoringConfiguration', '#microsoft.graph.iosGeneralDeviceConfiguration', '#microsoft.graph.androidGeneralDeviceConfiguration', '#microsoft.graph.macOSGeneralDeviceConfiguration', @@ -165,6 +167,8 @@ '#microsoft.graph.windows10CompliancePolicy', '#microsoft.graph.iosCompliancePolicy', '#microsoft.graph.androidCompliancePolicy', + '#microsoft.graph.androidDeviceOwnerCompliancePolicy', + '#microsoft.graph.androidWorkProfileCompliancePolicy', '#microsoft.graph.macOSCompliancePolicy', ], 'create_method' => 'POST', @@ -190,6 +194,7 @@ '#microsoft.graph.targetedManagedAppProtection', '#microsoft.graph.iosManagedAppProtection', '#microsoft.graph.androidManagedAppProtection', + '#microsoft.graph.windowsManagedAppProtection', '#microsoft.graph.windowsInformationProtectionPolicy', '#microsoft.graph.mdmWindowsInformationProtectionPolicy', ], @@ -289,6 +294,8 @@ 'type_family' => [ '#microsoft.graph.deviceEnrollmentConfiguration', '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration', + '#microsoft.graph.deviceEnrollmentWindowsHelloForBusinessConfiguration', + '#microsoft.graph.windowsRestoreDeviceEnrollmentConfiguration', ], 'create_method' => 'POST', 'update_method' => 'PATCH', diff --git a/specs/011-restore-run-wizard/tasks.md b/specs/011-restore-run-wizard/tasks.md index e6c7b48..f20fb48 100644 --- a/specs/011-restore-run-wizard/tasks.md +++ b/specs/011-restore-run-wizard/tasks.md @@ -38,6 +38,7 @@ ## Phase 7 — Tests + Formatting - [x] T018 Add Pest tests for wizard gating rules and status transitions. - [x] T019 Add Pest tests for safety checks persistence and blocking behavior. - [x] T020 Add Pest tests for preview summary generation. +- [x] T024 Fix Graph contract type families to accept valid derived @odata.type values during restore. - [x] T021 Run `./vendor/bin/pint --dirty`. - [x] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist). diff --git a/tests/Pest.php b/tests/Pest.php index 4baf965..2e65b3a 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -17,6 +17,9 @@ ->use(RefreshDatabase::class) ->in('Feature'); +pest()->extend(Tests\TestCase::class) + ->in('Unit'); + beforeEach(function () { putenv('INTUNE_TENANT_ID'); unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']); diff --git a/tests/Unit/ODataTypeValidationTest.php b/tests/Unit/ODataTypeValidationTest.php new file mode 100644 index 0000000..b4cd370 --- /dev/null +++ b/tests/Unit/ODataTypeValidationTest.php @@ -0,0 +1,38 @@ + $odataType], 'deviceConfiguration', 'all'); + + expect($result['matches'])->toBeTrue(); +})->with([ + '#microsoft.graph.windows10GeneralConfiguration', + '#microsoft.graph.windowsHealthMonitoringConfiguration', +]); + +it('accepts derived deviceCompliancePolicy odata types', function (string $odataType) { + $result = BackupItem::validateODataType(['@odata.type' => $odataType], 'deviceCompliancePolicy', 'all'); + + expect($result['matches'])->toBeTrue(); +})->with([ + '#microsoft.graph.androidDeviceOwnerCompliancePolicy', + '#microsoft.graph.androidWorkProfileCompliancePolicy', +]); + +it('accepts derived appProtectionPolicy odata types', function (string $odataType) { + $result = BackupItem::validateODataType(['@odata.type' => $odataType], 'appProtectionPolicy', 'mobile'); + + expect($result['matches'])->toBeTrue(); +})->with([ + '#microsoft.graph.windowsManagedAppProtection', +]); + +it('accepts derived enrollmentRestriction odata types', function (string $odataType) { + $result = BackupItem::validateODataType(['@odata.type' => $odataType], 'enrollmentRestriction', 'all'); + + expect($result['matches'])->toBeTrue(); +})->with([ + '#microsoft.graph.deviceEnrollmentWindowsHelloForBusinessConfiguration', + '#microsoft.graph.windowsRestoreDeviceEnrollmentConfiguration', +]); -- 2.45.2