Compare commits
4 Commits
dev
...
feat/048-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d4607c037 | ||
|
|
08781297e1 | ||
|
|
bf183347ac | ||
|
|
e9994aa5cc |
@ -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.')');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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`
|
||||
|
||||
|
||||
@ -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 `…<last8>`.
|
||||
|
||||
### 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.
|
||||
|
||||
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
34
tests/Feature/Filament/BackupSetGraphSafetyTest.php
Normal file
34
tests/Feature/Filament/BackupSetGraphSafetyTest.php
Normal 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);
|
||||
});
|
||||
65
tests/Feature/Filament/RestoreWizardGraphSafetyTest.php
Normal file
65
tests/Feature/Filament/RestoreWizardGraphSafetyTest.php
Normal 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.');
|
||||
});
|
||||
@ -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}
|
||||
*/
|
||||
|
||||
45
tests/Support/FailHardGraphClient.php
Normal file
45
tests/Support/FailHardGraphClient.php
Normal 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__);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user