feat/048-backup-restore-ui-graph-safety (#55)

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 (…<last8>)

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 <ahmeddarrazi@adsmac.local>
Reviewed-on: #55
This commit is contained in:
ahmido 2026-01-11 00:14:35 +00:00
parent b35e3a6518
commit 060a82a1ed
7 changed files with 259 additions and 149 deletions

View File

@ -12,8 +12,6 @@
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\BulkOperationService; use App\Services\BulkOperationService;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GroupResolver;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Intune\RestoreDiffGenerator; use App\Services\Intune\RestoreDiffGenerator;
use App\Services\Intune\RestoreRiskChecker; use App\Services\Intune\RestoreRiskChecker;
@ -110,20 +108,40 @@ public static function form(Schema $schema): Schema
tenant: $tenant 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']; $groupId = $group['id'];
$label = $group['label']; $label = $group['label'];
return Forms\Components\Select::make("group_mapping.{$groupId}") return Forms\Components\TextInput::make("group_mapping.{$groupId}")
->label($label) ->label($label)
->options([ ->placeholder('SKIP or target group Object ID (GUID)')
'SKIP' => 'Skip assignment', ->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() ->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); }, $unresolved);
}) })
->visible(function (Get $get): bool { ->visible(function (Get $get): bool {
@ -320,18 +338,38 @@ public static function getWizardSteps(): array
tenant: $tenant 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']; $groupId = $group['id'];
$label = $group['label']; $label = $group['label'];
return Forms\Components\Select::make("group_mapping.{$groupId}") return Forms\Components\TextInput::make("group_mapping.{$groupId}")
->label($label) ->label($label)
->options([ ->placeholder('SKIP or target group Object ID (GUID)')
'SKIP' => 'Skip assignment', ->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() ->reactive()
->afterStateUpdated(function (Set $set): void { ->afterStateUpdated(function (Set $set): void {
$set('check_summary', null); $set('check_summary', null);
@ -341,7 +379,8 @@ public static function getWizardSteps(): array
$set('preview_diffs', []); $set('preview_diffs', []);
$set('preview_ran_at', null); $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); }, $unresolved);
}) })
->visible(function (Get $get): bool { ->visible(function (Get $get): bool {
@ -1382,27 +1421,12 @@ private static function unresolvedGroups(?int $backupSetId, ?array $selectedItem
return []; return [];
} }
$graphOptions = $tenant->graphOptions(); return array_map(function (string $groupId) use ($sourceNames): array {
$tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey(); return [
$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[] = [
'id' => $groupId, 'id' => $groupId,
'label' => $label, 'label' => static::formatGroupLabel($sourceNames[$groupId] ?? null, $groupId),
]; ];
} }, $groupIds);
return $unresolved;
} }
/** /**
@ -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_filter($result, static fn (?string $value): bool => is_string($value) && $value !== '');
} }
/**
* @return array<string, string>
*/
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 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); return trim(($displayName ?: 'Security group').' ('.$suffix.')');
}
private static function escapeOdataValue(string $value): string
{
return str_replace("'", "''", $value);
} }
} }

View File

@ -18,10 +18,14 @@ ## Local setup (Sail)
- `./vendor/bin/sail artisan migrate` - `./vendor/bin/sail artisan migrate`
## Run the targeted tests (once implemented) ## 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` - `./vendor/bin/sail artisan test tests/Feature/Filament/*GraphSafety*Test.php`

View File

@ -18,9 +18,8 @@ ## Format: `- [ ] T### [P?] [US#?] Description with file path`
## Phase 1: Setup (Shared Infrastructure) ## 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. **Purpose**: Confirm scope, lock stable UI markers as concrete strings, and ensure the contracts/quickstart reflect the intended test approach.
- [x] T001 Confirm tenant-scoped admin URLs for target pages in specs/048-backup-restore-ui-graph-safety/contracts/admin-pages.openapi.yaml
- [ ] 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:
- [ ] T002 Lock stable marker strings and record them in specs/048-backup-restore-ui-graph-safety/quickstart.md:
- Backup Sets index marker: `Created by` - Backup Sets index marker: `Created by`
- Restore wizard create marker: `Create restore run` (and wizard step: `Select Backup Set`) - 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. **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 storys feature tests. **⚠️ CRITICAL**: No user story work should be considered “done” unless the fail-hard Graph binding is used in the storys feature tests.
- [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)
- [ ] 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
- [ ] 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. **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. **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 ### Tests
- [x] T005 [P] [US1] Add Pest feature test in tests/Feature/Filament/BackupSetGraphSafetyTest.php:
- [ ] T005 [P] [US1] Add Pest feature test in tests/Feature/Filament/BackupSetGraphSafetyTest.php:
- bind GraphClientInterface to FailHardGraphClient (fail-hard on ANY invocation) - bind GraphClientInterface to FailHardGraphClient (fail-hard on ANY invocation)
- HTTP GET App\\Filament\\Resources\\BackupSetResource::getUrl('index', tenant: $tenant) - HTTP GET App\\Filament\\Resources\\BackupSetResource::getUrl('index', tenant: $tenant)
- assertOk() + assertSee('Created by') - 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 ### 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. **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 `…<last8>`. **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 `…<last8>`.
### Tests ### Tests
- [x] T008 [P] [US2] Add Pest feature test in tests/Feature/Filament/RestoreWizardGraphSafetyTest.php:
- [ ] T008 [P] [US2] Add Pest feature test in tests/Feature/Filament/RestoreWizardGraphSafetyTest.php:
- bind GraphClientInterface to FailHardGraphClient (fail-hard on ANY invocation) - bind GraphClientInterface to FailHardGraphClient (fail-hard on ANY invocation)
- HTTP GET App\\Filament\\Resources\\RestoreRunResource::getUrl('create', tenant: $tenant) - HTTP GET App\\Filament\\Resources\\RestoreRunResource::getUrl('create', tenant: $tenant)
- assertOk() + assertSee('Create restore run') + assertSee('Select Backup Set') - 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) - 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 - 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) - 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('…<last8>') masked fallback appears for source labels - assertSee('…<last8>') masked fallback appears for source labels
- assertSee('Paste the target Entra ID group Object ID') helper text appears - assertSee('Paste the target Entra ID group Object ID') helper text appears
- assertSee('Use SKIP to omit the assignment.') helper text appears - assertSee('Use SKIP to omit the assignment.') helper text appears
### Implementation ### Implementation
- [x] T011 [US2] Remove Graph-dependent typeahead/search from group mapping controls in app/Filament/Resources/RestoreRunResource.php (no Graph/typeahead; remove getSearchResultsUsing paths)
- [ ] 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)
- [ ] 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:
- [ ] T013 [US2] Implement DB-only group mapping UX in app/Filament/Resources/RestoreRunResource.php:
- manual target group objectId input (GUID/UUID) - manual target group objectId input (GUID/UUID)
- GUID validation (if not SKIP) - 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.” - 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 - 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) - [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)
- [ ] T015 [US2] Implement masked fallback label formatting `…<last8>` in app/Filament/Resources/RestoreRunResource.php (update formatGroupLabel() and ensure all source labels route through it) - [x] T015 [US2] Implement masked fallback label formatting `…<last8>` 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] 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. **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 ## Phase 5: Polish & Cross-Cutting Concerns
**Purpose**: Keep docs and tooling aligned with the guardrail. **Purpose**: Keep docs and tooling aligned with the guardrail.
- [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
- [ ] 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
- [ ] 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)
- [ ] T019 Run formatting (./vendor/bin/pint --dirty) and targeted tests (./vendor/bin/sail artisan test --filter=graph\-safety or the exact files)
--- ---

View File

@ -0,0 +1,34 @@
<?php
use App\Filament\Resources\BackupSetResource;
use App\Models\BackupSet;
use App\Models\Tenant;
test('backup sets index renders without touching graph', function () {
$tenant = Tenant::factory()->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);
});

View File

@ -0,0 +1,65 @@
<?php
use App\Filament\Resources\RestoreRunResource;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Tenant;
function makeAssignment(string $odataType, string $groupId, ?string $displayName = null): array
{
$target = [
'@odata.type' => $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.');
});

View File

@ -2,7 +2,9 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use Illuminate\Foundation\Testing\RefreshDatabase; 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} * @return array{0: User, 1: Tenant}
*/ */

View File

@ -0,0 +1,45 @@
<?php
namespace Tests\Support;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use RuntimeException;
final class FailHardGraphClient implements GraphClientInterface
{
private function fail(string $method): never
{
throw new RuntimeException("GraphClientInterface invoked during UI render/guard test: {$method}");
}
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
$this->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__);
}
}