From 060a82a1ed786e089cb282e2694e390c91ef76d1 Mon Sep 17 00:00:00 2001 From: ahmido Date: Sun, 11 Jan 2026 00:14:35 +0000 Subject: [PATCH] feat/048-backup-restore-ui-graph-safety (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature 048: Backup/Restore UI Graph-Safety (Phase 1) Dieses PR entfernt Microsoft Graph Calls aus UI-Renderpfaden (Filament/Livewire mount/render/options/typeahead/labels) in den kritischen Backup/Restore Screens und fügt Fail-Hard Guard Tests hinzu, die regressionssicher verhindern, dass UI-Rendering wieder Graph aufruft. ⸻ Motivation Backup/Restore UI wurde teilweise “fragil”, weil UI-Komponenten (z.B. Group Typeahead/Option Labels) Graph/Entra direkt beim Rendern triggern konnten. Das führt zu: • langsamen/unstabilen Seiten (429/Timeout/Permissions) • schwer reproduzierbaren UI-Fehlern im MSP-Scale • unnötiger Kopplung von “Page render” an Graph-Verfügbarkeit Ziel: UI muss DB-only rendern; Graph darf nur in Jobs/Run-Execution stattfinden. ⸻ Scope / Changes 1) Restore Wizard: Entfernt Graph-Typeahead & Label-Resolution • Group Mapping ist jetzt DB-only: • manuelle GUID Eingabe / Skip • GUID Validation • Helper Text, wo die Object ID zu finden ist • Keine Graph calls mehr in options() / getOptionLabelUsing() / typeahead beim Rendern. 2) Fail-Hard Guard Tests (Graph-Safety) • Neue Test-Infrastruktur: FailHardGraphClient (GraphClientInterface darf nicht aufgerufen werden) • Guard Tests als Pest Feature Tests (HTTP GET): • Backup Sets Index rendert mit fail-hard Graph client • Restore Wizard Route rendert mit fail-hard Graph client • Assertions: • 200 OK • plus stable UI marker string • Masking/Fallback Format ist deterministisch: Unresolved (…) 3) Spec/Plan/Tasks/Checklist • Spec 048 aktualisiert, Tasks abgehakt • requirements.md Checklist Gate: PASS ⸻ Out of Scope / Non-Goals • Kein Umbau der “Execution”-Actions zu Jobs (Capture snapshot, Restore rerun, Dry-Run execution etc.) → eigener Folge-Spec (Phase 2). • Keine Entra Group Name Resolution (separates Group-Inventory/Cache Feature). • Keine neuen Tabellen/Migrations in Phase 1. ⸻ How to verify (manual) Mit absichtlich kaputtem Tenant/Auth (Graph failt): 1. Öffne Backups & Restore → Backup Sets ✅ muss laden (UI render DB-only) 2. Öffne Restore Runs → Create Restore Run (Wizard) ✅ muss laden, kein Group-Typeahead mehr 3. Starte eine Restore Operation ❌ darf fehlschlagen (Graph kaputt) – wichtig ist: Render bleibt stabil, Run zeigt Fehler sauber pro Item. ⸻ Tests / Validation Executed: • ./vendor/bin/pint --dirty ✅ • ./vendor/bin/sail artisan test tests/Feature/Filament/BackupSetGraphSafetyTest.php tests/Feature/Filament/RestoreWizardGraphSafetyTest.php ✅ • (optional) Combined targeted suite ✅ ⸻ Notes • This PR intentionally focuses on UI Graph-Safety only. • Any future reintroduction of Graph search/typeahead in UI must go through contracts first and be executed asynchronously, never in UI render paths. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/55 --- app/Filament/Resources/RestoreRunResource.php | 205 +++++++----------- .../quickstart.md | 8 +- .../tasks.md | 44 ++-- .../Filament/BackupSetGraphSafetyTest.php | 34 +++ .../Filament/RestoreWizardGraphSafetyTest.php | 65 ++++++ tests/Pest.php | 7 + tests/Support/FailHardGraphClient.php | 45 ++++ 7 files changed, 259 insertions(+), 149 deletions(-) create mode 100644 tests/Feature/Filament/BackupSetGraphSafetyTest.php create mode 100644 tests/Feature/Filament/RestoreWizardGraphSafetyTest.php create mode 100644 tests/Support/FailHardGraphClient.php diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index ff2500b..516e6c1 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -12,8 +12,6 @@ use App\Models\RestoreRun; use App\Models\Tenant; 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; @@ -110,20 +108,40 @@ public static function form(Schema $schema): Schema tenant: $tenant ); - return array_map(function (array $group) use ($tenant): Forms\Components\Select { + return array_map(function (array $group): Forms\Components\TextInput { $groupId = $group['id']; $label = $group['label']; - return Forms\Components\Select::make("group_mapping.{$groupId}") + return Forms\Components\TextInput::make("group_mapping.{$groupId}") ->label($label) - ->options([ - 'SKIP' => 'Skip assignment', + ->placeholder('SKIP or target group Object ID (GUID)') + ->rules([ + static function (string $attribute, mixed $value, \Closure $fail): void { + if (! is_string($value)) { + $fail('Please enter SKIP or a valid UUID.'); + + return; + } + + $value = trim($value); + + if ($value === '') { + $fail('Please enter SKIP or a valid UUID.'); + + return; + } + + if (strtoupper($value) === 'SKIP') { + return; + } + + if (! Str::isUuid($value)) { + $fail('Please enter SKIP or a valid UUID.'); + } + }, ]) - ->searchable() - ->getSearchResultsUsing(fn (string $search) => static::targetGroupOptions($tenant, $search)) - ->getOptionLabelUsing(fn ($value) => static::resolveTargetGroupLabel($tenant, $value)) ->required() - ->helperText('Choose a target group or select Skip.'); + ->helperText('Paste the target Entra ID group Object ID (GUID). Names are not resolved in this phase. Use SKIP to omit the assignment.'); }, $unresolved); }) ->visible(function (Get $get): bool { @@ -272,29 +290,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.') @@ -320,18 +338,38 @@ public static function getWizardSteps(): array tenant: $tenant ); - return array_map(function (array $group) use ($tenant): Forms\Components\Select { + return array_map(function (array $group): Forms\Components\TextInput { $groupId = $group['id']; $label = $group['label']; - return Forms\Components\Select::make("group_mapping.{$groupId}") + return Forms\Components\TextInput::make("group_mapping.{$groupId}") ->label($label) - ->options([ - 'SKIP' => 'Skip assignment', + ->placeholder('SKIP or target group Object ID (GUID)') + ->rules([ + static function (string $attribute, mixed $value, \Closure $fail): void { + if (! is_string($value)) { + $fail('Please enter SKIP or a valid UUID.'); + + return; + } + + $value = trim($value); + + if ($value === '') { + $fail('Please enter SKIP or a valid UUID.'); + + return; + } + + if (strtoupper($value) === 'SKIP') { + return; + } + + if (! Str::isUuid($value)) { + $fail('Please enter SKIP or a valid UUID.'); + } + }, ]) - ->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); @@ -341,7 +379,8 @@ public static function getWizardSteps(): array $set('preview_diffs', []); $set('preview_ran_at', null); }) - ->helperText('Choose a target group or select Skip.'); + ->required() + ->helperText('Paste the target Entra ID group Object ID (GUID). Names are not resolved in this phase. Use SKIP to omit the assignment.'); }, $unresolved); }) ->visible(function (Get $get): bool { @@ -1382,27 +1421,12 @@ private static function unresolvedGroups(?int $backupSetId, ?array $selectedItem return []; } - $graphOptions = $tenant->graphOptions(); - $tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey(); - $resolved = app(GroupResolver::class)->resolveGroupIds($groupIds, $tenantIdentifier, $graphOptions); - - $unresolved = []; - - foreach ($groupIds as $groupId) { - $group = $resolved[$groupId] ?? null; - - if (! is_array($group) || ! ($group['orphaned'] ?? false)) { - continue; - } - - $label = static::formatGroupLabel($sourceNames[$groupId] ?? null, $groupId); - $unresolved[] = [ + return array_map(function (string $groupId) use ($sourceNames): array { + return [ 'id' => $groupId, - 'label' => $label, + 'label' => static::formatGroupLabel($sourceNames[$groupId] ?? null, $groupId), ]; - } - - return $unresolved; + }, $groupIds); } /** @@ -1510,73 +1534,10 @@ private static function normalizeGroupMapping(mixed $mapping): array return array_filter($result, static fn (?string $value): bool => is_string($value) && $value !== ''); } - /** - * @return array - */ - private static function targetGroupOptions(Tenant $tenant, string $search): array - { - if (mb_strlen($search) < 2) { - return []; - } - - try { - $response = app(GraphClientInterface::class)->request( - 'GET', - 'groups', - [ - 'query' => [ - '$filter' => sprintf( - "securityEnabled eq true and startswith(displayName,'%s')", - static::escapeOdataValue($search) - ), - '$select' => 'id,displayName', - '$top' => 20, - ], - ] + $tenant->graphOptions() - ); - } catch (\Throwable) { - return []; - } - - if ($response->failed()) { - return []; - } - - return collect($response->data['value'] ?? []) - ->filter(fn (array $group) => filled($group['id'] ?? null)) - ->mapWithKeys(fn (array $group) => [ - $group['id'] => static::formatGroupLabel($group['displayName'] ?? null, $group['id']), - ]) - ->all(); - } - - private static function resolveTargetGroupLabel(Tenant $tenant, ?string $groupId): ?string - { - if (! $groupId) { - return $groupId; - } - - if ($groupId === 'SKIP') { - return 'Skip assignment'; - } - - $graphOptions = $tenant->graphOptions(); - $tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey(); - $resolved = app(GroupResolver::class)->resolveGroupIds([$groupId], $tenantIdentifier, $graphOptions); - $group = $resolved[$groupId] ?? null; - - return static::formatGroupLabel($group['displayName'] ?? null, $groupId); - } - private static function formatGroupLabel(?string $displayName, string $id): string { - $suffix = sprintf(' (%s)', Str::limit($id, 8, '')); + $suffix = '…'.mb_substr($id, -8); - return trim(($displayName ?: 'Security group').$suffix); - } - - private static function escapeOdataValue(string $value): string - { - return str_replace("'", "''", $value); + return trim(($displayName ?: 'Security group').' ('.$suffix.')'); } } diff --git a/specs/048-backup-restore-ui-graph-safety/quickstart.md b/specs/048-backup-restore-ui-graph-safety/quickstart.md index fb8d921..9101ff9 100644 --- a/specs/048-backup-restore-ui-graph-safety/quickstart.md +++ b/specs/048-backup-restore-ui-graph-safety/quickstart.md @@ -18,10 +18,14 @@ ## Local setup (Sail) - `./vendor/bin/sail artisan migrate` ## Run the targeted tests (once implemented) +- `./vendor/bin/sail artisan test tests/Feature/Filament/BackupSetGraphSafetyTest.php` +- `./vendor/bin/sail artisan test tests/Feature/Filament/RestoreWizardGraphSafetyTest.php` -- `./vendor/bin/sail artisan test --filter=graph\-safety` +Or run both in one command: -Or run specific files (names TBD when tests land): +- `./vendor/bin/sail artisan test tests/Feature/Filament/BackupSetGraphSafetyTest.php tests/Feature/Filament/RestoreWizardGraphSafetyTest.php` + +Or run via glob: - `./vendor/bin/sail artisan test tests/Feature/Filament/*GraphSafety*Test.php` diff --git a/specs/048-backup-restore-ui-graph-safety/tasks.md b/specs/048-backup-restore-ui-graph-safety/tasks.md index 8347554..7d2a5a8 100644 --- a/specs/048-backup-restore-ui-graph-safety/tasks.md +++ b/specs/048-backup-restore-ui-graph-safety/tasks.md @@ -18,9 +18,8 @@ ## Format: `- [ ] T### [P?] [US#?] Description with file path` ## Phase 1: Setup (Shared Infrastructure) **Purpose**: Confirm scope, lock stable UI markers as concrete strings, and ensure the contracts/quickstart reflect the intended test approach. - -- [ ] T001 Confirm tenant-scoped admin URLs for target pages in specs/048-backup-restore-ui-graph-safety/contracts/admin-pages.openapi.yaml -- [ ] T002 Lock stable marker strings and record them in specs/048-backup-restore-ui-graph-safety/quickstart.md: +- [x] T001 Confirm tenant-scoped admin URLs for target pages in specs/048-backup-restore-ui-graph-safety/contracts/admin-pages.openapi.yaml +- [x] T002 Lock stable marker strings and record them in specs/048-backup-restore-ui-graph-safety/quickstart.md: - Backup Sets index marker: `Created by` - Restore wizard create marker: `Create restore run` (and wizard step: `Select Backup Set`) @@ -31,9 +30,8 @@ ## Phase 2: Foundational (Blocking Prerequisites) **Purpose**: Shared test helpers and a clear boundary that fails-hard when Graph is touched. **⚠️ CRITICAL**: No user story work should be considered “done” unless the fail-hard Graph binding is used in the story’s feature tests. - -- [ ] T003 [P] Create a fail-hard Graph client test double in tests/Support/FailHardGraphClient.php (implements App\\Services\\Graph\\GraphClientInterface and throws on any method) -- [ ] T004 Add a reusable binding helper for tests (either a helper function in tests/Pest.php or a trait in tests/Support/) that binds GraphClientInterface to FailHardGraphClient +- [x] T003 [P] Create a fail-hard Graph client test double in tests/Support/FailHardGraphClient.php (implements App\\Services\\Graph\\GraphClientInterface and throws on any method) +- [x] T004 Add a reusable binding helper for tests (either a helper function in tests/Pest.php or a trait in tests/Support/) that binds GraphClientInterface to FailHardGraphClient **Checkpoint**: Foundation ready — both page-render tests can now be implemented. @@ -46,16 +44,15 @@ ## Phase 3: User Story 1 — Backup Sets index renders Graph-free (Priority: P1) **Independent Test**: A Pest feature test does an HTTP GET to the tenant-scoped Filament Backup Sets index route and asserts assertOk() + assertSee('Created by') — with Graph bound to fail-hard. ### Tests - -- [ ] T005 [P] [US1] Add Pest feature test in tests/Feature/Filament/BackupSetGraphSafetyTest.php: +- [x] T005 [P] [US1] Add Pest feature test in tests/Feature/Filament/BackupSetGraphSafetyTest.php: - bind GraphClientInterface to FailHardGraphClient (fail-hard on ANY invocation) - HTTP GET App\\Filament\\Resources\\BackupSetResource::getUrl('index', tenant: $tenant) - assertOk() + assertSee('Created by') -- [ ] T006 [US1] In tests/Feature/Filament/BackupSetGraphSafetyTest.php, add tenant isolation assertions (second tenant data must not render) while still using fail-hard Graph binding +- [x] T006 [US1] In tests/Feature/Filament/BackupSetGraphSafetyTest.php, add tenant isolation assertions (second tenant data must not render) while still using fail-hard Graph binding ### Implementation -- [ ] T007 [US1] Audit Backup Sets render path for any Graph usage and refactor to DB-only if needed in app/Filament/Resources/BackupSetResource.php and app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php +- [x] T007 [US1] Audit Backup Sets render path for any Graph usage and refactor to DB-only if needed in app/Filament/Resources/BackupSetResource.php and app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php **Checkpoint**: Backup Sets index renders (assertOk() + assertSee('Created by')) with fail-hard Graph binding. @@ -68,32 +65,30 @@ ## Phase 4: User Story 2 — Restore wizard renders Graph-free + DB-only group m **Independent Test**: Pest feature tests that (a) render the Restore wizard create page without Graph, and (b) render the group mapping section (using query params to preselect a backup set with group assignments) and verify fallback labels use `…`. ### Tests - -- [ ] T008 [P] [US2] Add Pest feature test in tests/Feature/Filament/RestoreWizardGraphSafetyTest.php: +- [x] T008 [P] [US2] Add Pest feature test in tests/Feature/Filament/RestoreWizardGraphSafetyTest.php: - bind GraphClientInterface to FailHardGraphClient (fail-hard on ANY invocation) - HTTP GET App\\Filament\\Resources\\RestoreRunResource::getUrl('create', tenant: $tenant) - assertOk() + assertSee('Create restore run') + assertSee('Select Backup Set') -- [ ] T009 [P] [US2] Extend tests/Feature/Filament/RestoreWizardGraphSafetyTest.php (or a second file): +- [x] T009 [P] [US2] Extend tests/Feature/Filament/RestoreWizardGraphSafetyTest.php (or a second file): - seed a BackupSet + BackupItem with group assignment targets (groupId present) - HTTP GET create URL with `?backup_set_id=` (and optional `backup_item_ids`/`scope_mode`) to force group mapping render - keep fail-hard Graph binding (no Graph/typeahead/label resolution allowed) -- [ ] T010 [US2] In the group mapping render test, assert all DB-only UX requirements: +- [x] T010 [US2] In the group mapping render test, assert all DB-only UX requirements: - assertSee('…') masked fallback appears for source labels - assertSee('Paste the target Entra ID group Object ID') helper text appears - assertSee('Use SKIP to omit the assignment.') helper text appears ### Implementation - -- [ ] T011 [US2] Remove Graph-dependent typeahead/search from group mapping controls in app/Filament/Resources/RestoreRunResource.php (no Graph/typeahead; remove getSearchResultsUsing paths) -- [ ] T012 [US2] Remove Graph-dependent option label resolution in app/Filament/Resources/RestoreRunResource.php (no Graph label resolution; remove getOptionLabelUsing paths) -- [ ] T013 [US2] Implement DB-only group mapping UX in app/Filament/Resources/RestoreRunResource.php: +- [x] T011 [US2] Remove Graph-dependent typeahead/search from group mapping controls in app/Filament/Resources/RestoreRunResource.php (no Graph/typeahead; remove getSearchResultsUsing paths) +- [x] T012 [US2] Remove Graph-dependent option label resolution in app/Filament/Resources/RestoreRunResource.php (no Graph label resolution; remove getOptionLabelUsing paths) +- [x] T013 [US2] Implement DB-only group mapping UX in app/Filament/Resources/RestoreRunResource.php: - manual target group objectId input (GUID/UUID) - GUID validation (if not SKIP) - helper text: “Paste the target Entra ID group Object ID (GUID). Names are not resolved in this phase.” + “Use SKIP to omit the assignment.” - no Graph/typeahead -- [ ] T014 [US2] Make unresolved group detection DB-only in app/Filament/Resources/RestoreRunResource.php (remove GroupResolver usage from unresolvedGroups() and any other render-time helpers) -- [ ] T015 [US2] Implement masked fallback label formatting `…` in app/Filament/Resources/RestoreRunResource.php (update formatGroupLabel() and ensure all source labels route through it) -- [ ] T016 [US2] Remove now-unused methods/imports after refactor (e.g., targetGroupOptions(), resolveTargetGroupLabel(), GroupResolver import) in app/Filament/Resources/RestoreRunResource.php +- [x] T014 [US2] Make unresolved group detection DB-only in app/Filament/Resources/RestoreRunResource.php (remove GroupResolver usage from unresolvedGroups() and any other render-time helpers) +- [x] T015 [US2] Implement masked fallback label formatting `…` in app/Filament/Resources/RestoreRunResource.php (update formatGroupLabel() and ensure all source labels route through it) +- [x] T016 [US2] Remove now-unused methods/imports after refactor (e.g., targetGroupOptions(), resolveTargetGroupLabel(), GroupResolver import) in app/Filament/Resources/RestoreRunResource.php **Checkpoint**: Restore wizard renders (assertOk() + assertSee('Create restore run') + assertSee('Select Backup Set')) and group mapping renders DB-only; tests pass with fail-hard Graph binding. @@ -102,10 +97,9 @@ ### Implementation ## Phase 5: Polish & Cross-Cutting Concerns **Purpose**: Keep docs and tooling aligned with the guardrail. - -- [ ] T017 [P] Update specs/048-backup-restore-ui-graph-safety/quickstart.md with the final test file names and the exact `artisan test --filter=...` / file commands -- [ ] T018 [P] Update specs/048-backup-restore-ui-graph-safety/contracts/admin-pages.openapi.yaml if any page paths/markers changed during implementation -- [ ] T019 Run formatting (./vendor/bin/pint --dirty) and targeted tests (./vendor/bin/sail artisan test --filter=graph\-safety or the exact files) +- [x] T017 [P] Update specs/048-backup-restore-ui-graph-safety/quickstart.md with the final test file names and the exact `artisan test --filter=...` / file commands +- [x] T018 [P] Update specs/048-backup-restore-ui-graph-safety/contracts/admin-pages.openapi.yaml if any page paths/markers changed during implementation +- [x] T019 Run formatting (./vendor/bin/pint --dirty) and targeted tests (./vendor/bin/sail artisan test --filter=graph\-safety or the exact files) --- diff --git a/tests/Feature/Filament/BackupSetGraphSafetyTest.php b/tests/Feature/Filament/BackupSetGraphSafetyTest.php new file mode 100644 index 0000000..5702939 --- /dev/null +++ b/tests/Feature/Filament/BackupSetGraphSafetyTest.php @@ -0,0 +1,34 @@ +create(); + $otherTenant = Tenant::factory()->create(); + + [$user] = createUserWithTenant($tenant); + $user->tenants()->syncWithoutDetaching([ + $otherTenant->getKey() => ['role' => 'owner'], + ]); + + $visibleSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'name' => 'visible-backup-set', + ]); + + $hiddenSet = BackupSet::factory()->create([ + 'tenant_id' => $otherTenant->getKey(), + 'name' => 'hidden-backup-set', + ]); + + bindFailHardGraphClient(); + + $this->actingAs($user) + ->get(BackupSetResource::getUrl('index', tenant: $tenant)) + ->assertOk() + ->assertSee('Created by') + ->assertSee($visibleSet->name) + ->assertDontSee($hiddenSet->name); +}); diff --git a/tests/Feature/Filament/RestoreWizardGraphSafetyTest.php b/tests/Feature/Filament/RestoreWizardGraphSafetyTest.php new file mode 100644 index 0000000..5fa4058 --- /dev/null +++ b/tests/Feature/Filament/RestoreWizardGraphSafetyTest.php @@ -0,0 +1,65 @@ + $odataType, + 'groupId' => $groupId, + ]; + + if (is_string($displayName) && $displayName !== '') { + $target['group_display_name'] = $displayName; + } + + return ['target' => $target]; +} + +test('restore wizard create page renders without touching graph', function () { + $tenant = Tenant::factory()->create(); + [$user] = createUserWithTenant($tenant); + + bindFailHardGraphClient(); + + $this->actingAs($user) + ->get(RestoreRunResource::getUrl('create', tenant: $tenant)) + ->assertOk() + ->assertSee('Create restore run') + ->assertSee('Select Backup Set'); +}); + +test('restore wizard group mapping renders DB-only with manual GUID UX', function () { + $tenant = Tenant::factory()->create(); + [$user] = createUserWithTenant($tenant); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'name' => 'group-mapping-backup-set', + ]); + + $groupId = '11111111-2222-3333-4444-555555555555'; + $expectedMasked = '…'.substr($groupId, -8); + + BackupItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'backup_set_id' => $backupSet->getKey(), + 'assignments' => [ + makeAssignment('#microsoft.graph.groupAssignmentTarget', $groupId, 'Example Group'), + ], + ]); + + bindFailHardGraphClient(); + + $url = RestoreRunResource::getUrl('create', tenant: $tenant).'?backup_set_id='.$backupSet->getKey(); + + $this->actingAs($user) + ->get($url) + ->assertOk() + ->assertSee($expectedMasked) + ->assertSee('Paste the target Entra ID group Object ID') + ->assertSee('Use SKIP to omit the assignment.'); +}); diff --git a/tests/Pest.php b/tests/Pest.php index dccc767..36dc985 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,7 +2,9 @@ use App\Models\Tenant; use App\Models\User; +use App\Services\Graph\GraphClientInterface; use Illuminate\Foundation\Testing\RefreshDatabase; +use Tests\Support\FailHardGraphClient; /* |-------------------------------------------------------------------------- @@ -66,6 +68,11 @@ function something() // .. } +function bindFailHardGraphClient(): void +{ + app()->instance(GraphClientInterface::class, new FailHardGraphClient); +} + /** * @return array{0: User, 1: Tenant} */ diff --git a/tests/Support/FailHardGraphClient.php b/tests/Support/FailHardGraphClient.php new file mode 100644 index 0000000..1417c92 --- /dev/null +++ b/tests/Support/FailHardGraphClient.php @@ -0,0 +1,45 @@ +fail(__METHOD__); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + $this->fail(__METHOD__); + } + + public function getOrganization(array $options = []): GraphResponse + { + $this->fail(__METHOD__); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->fail(__METHOD__); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + $this->fail(__METHOD__); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->fail(__METHOD__); + } +}