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\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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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`
|
||||||
|
|
||||||
|
|||||||
@ -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 story’s feature tests.
|
**⚠️ CRITICAL**: No user story work should be considered “done” unless the fail-hard Graph binding is used in the story’s 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)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
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\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}
|
||||||
*/
|
*/
|
||||||
|
|||||||
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