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

Merged
ahmido merged 4 commits from feat/048-backup-restore-ui-graph-safety into dev 2026-01-11 00:14:35 +00:00
7 changed files with 259 additions and 144 deletions
Showing only changes of commit 08781297e1 - Show all commits

View File

@ -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<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
{
$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.')');
}
}

View File

@ -19,11 +19,12 @@ ## Local setup (Sail)
## Run the targeted tests (once implemented)
- `./vendor/bin/sail artisan test --filter=graph\-safety`
- `./vendor/bin/sail artisan test tests/Feature/Filament/BackupSetGraphSafetyTest.php`
- `./vendor/bin/sail artisan test tests/Feature/Filament/RestoreWizardGraphSafetyTest.php`
Or run specific files (names TBD when tests land):
Or run both in one command:
- `./vendor/bin/sail artisan test tests/Feature/Filament/*GraphSafety*Test.php`
- `./vendor/bin/sail artisan test tests/Feature/Filament/BackupSetGraphSafetyTest.php tests/Feature/Filament/RestoreWizardGraphSafetyTest.php`
## Formatting

View File

@ -19,8 +19,8 @@ ## 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`)
@ -32,8 +32,10 @@ ## Phase 2: Foundational (Blocking Prerequisites)
**⚠️ CRITICAL**: No user story work should be considered “done” unless the fail-hard Graph binding is used in the storys 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.
@ -47,15 +49,15 @@ ## Phase 3: User Story 1 — Backup Sets index renders Graph-free (Priority: P1)
### 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.
@ -69,31 +71,31 @@ ## Phase 4: User Story 2 — Restore wizard renders Graph-free + DB-only group m
### 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('…<last8>') 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 `…<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] 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 `…<last8>` 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.
@ -103,9 +105,9 @@ ## 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)
---

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\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}
*/

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