feat(004): add restore group mapping and assignment restore
This commit is contained in:
parent
0b8c0983a2
commit
cf2aff8188
@ -7,6 +7,8 @@
|
|||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Services\Graph\GroupResolver;
|
||||||
use App\Services\Intune\RestoreService;
|
use App\Services\Intune\RestoreService;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -15,10 +17,13 @@
|
|||||||
use Filament\Infolists;
|
use Filament\Infolists;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Components\Utilities\Get;
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
|
use Filament\Schemas\Components\Utilities\Set;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class RestoreRunResource extends Resource
|
class RestoreRunResource extends Resource
|
||||||
@ -54,6 +59,10 @@ public static function form(Schema $schema): Schema
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
->reactive()
|
->reactive()
|
||||||
|
->afterStateUpdated(function (Set $set): void {
|
||||||
|
$set('backup_item_ids', []);
|
||||||
|
$set('group_mapping', []);
|
||||||
|
})
|
||||||
->required(),
|
->required(),
|
||||||
Forms\Components\CheckboxList::make('backup_item_ids')
|
Forms\Components\CheckboxList::make('backup_item_ids')
|
||||||
->label('Items to restore (optional)')
|
->label('Items to restore (optional)')
|
||||||
@ -86,7 +95,57 @@ public static function form(Schema $schema): Schema
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
->columns(2)
|
->columns(2)
|
||||||
|
->reactive()
|
||||||
|
->afterStateUpdated(fn (Set $set) => $set('group_mapping', []))
|
||||||
->helperText('Preview-only types stay in dry-run; leave empty to include all items.'),
|
->helperText('Preview-only types stay in dry-run; leave empty to include all items.'),
|
||||||
|
Section::make('Group mapping')
|
||||||
|
->description('Some source groups do not exist in the target tenant. Map them or choose Skip.')
|
||||||
|
->schema(function (Get $get): array {
|
||||||
|
$backupSetId = $get('backup_set_id');
|
||||||
|
$selectedItemIds = $get('backup_item_ids');
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant || ! $backupSetId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$unresolved = static::unresolvedGroups(
|
||||||
|
backupSetId: $backupSetId,
|
||||||
|
selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null,
|
||||||
|
tenant: $tenant
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_map(function (array $group) use ($tenant): Forms\Components\Select {
|
||||||
|
$groupId = $group['id'];
|
||||||
|
$label = $group['label'];
|
||||||
|
|
||||||
|
return Forms\Components\Select::make("group_mapping.{$groupId}")
|
||||||
|
->label($label)
|
||||||
|
->options([
|
||||||
|
'SKIP' => 'Skip assignment',
|
||||||
|
])
|
||||||
|
->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.');
|
||||||
|
}, $unresolved);
|
||||||
|
})
|
||||||
|
->visible(function (Get $get): bool {
|
||||||
|
$backupSetId = $get('backup_set_id');
|
||||||
|
$selectedItemIds = $get('backup_item_ids');
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant || ! $backupSetId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::unresolvedGroups(
|
||||||
|
backupSetId: $backupSetId,
|
||||||
|
selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null,
|
||||||
|
tenant: $tenant
|
||||||
|
) !== [];
|
||||||
|
}),
|
||||||
Forms\Components\Toggle::make('is_dry_run')
|
Forms\Components\Toggle::make('is_dry_run')
|
||||||
->label('Preview only (dry-run)')
|
->label('Preview only (dry-run)')
|
||||||
->default(true),
|
->default(true),
|
||||||
@ -233,6 +292,161 @@ public static function createRestoreRun(array $data): RestoreRun
|
|||||||
dryRun: (bool) ($data['is_dry_run'] ?? true),
|
dryRun: (bool) ($data['is_dry_run'] ?? true),
|
||||||
actorEmail: auth()->user()?->email,
|
actorEmail: auth()->user()?->email,
|
||||||
actorName: auth()->user()?->name,
|
actorName: auth()->user()?->name,
|
||||||
|
groupMapping: $data['group_mapping'] ?? [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int>|null $selectedItemIds
|
||||||
|
* @return array<int, array{id:string,label:string}>
|
||||||
|
*/
|
||||||
|
private static function unresolvedGroups(?int $backupSetId, ?array $selectedItemIds, Tenant $tenant): array
|
||||||
|
{
|
||||||
|
if (! $backupSetId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = BackupItem::query()->where('backup_set_id', $backupSetId);
|
||||||
|
|
||||||
|
if ($selectedItemIds !== null) {
|
||||||
|
$query->whereIn('id', $selectedItemIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $query->get(['assignments']);
|
||||||
|
$assignments = [];
|
||||||
|
$sourceNames = [];
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if (! is_array($item->assignments) || $item->assignments === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($item->assignments as $assignment) {
|
||||||
|
if (! is_array($assignment)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = $assignment['target'] ?? [];
|
||||||
|
$odataType = $target['@odata.type'] ?? '';
|
||||||
|
|
||||||
|
if (! in_array($odataType, [
|
||||||
|
'#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'#microsoft.graph.exclusionGroupAssignmentTarget',
|
||||||
|
], true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupId = $target['groupId'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($groupId) || $groupId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$assignments[] = $groupId;
|
||||||
|
$displayName = $target['group_display_name'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($displayName) && $displayName !== '') {
|
||||||
|
$sourceNames[$groupId] = $displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupIds = array_values(array_unique($assignments));
|
||||||
|
|
||||||
|
if ($groupIds === []) {
|
||||||
|
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[] = [
|
||||||
|
'id' => $groupId,
|
||||||
|
'label' => $label,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $unresolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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, ''));
|
||||||
|
|
||||||
|
return trim(($displayName ?: 'Security group').$suffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function escapeOdataValue(string $value): string
|
||||||
|
{
|
||||||
|
return str_replace("'", "''", $value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
84
app/Jobs/RestoreAssignmentsJob.php
Normal file
84
app/Jobs/RestoreAssignmentsJob.php
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\RestoreRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\AssignmentRestoreService;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class RestoreAssignmentsJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public int $tries = 1;
|
||||||
|
|
||||||
|
public int $backoff = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new job instance.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public int $restoreRunId,
|
||||||
|
public int $tenantId,
|
||||||
|
public string $policyType,
|
||||||
|
public string $policyId,
|
||||||
|
public array $assignments,
|
||||||
|
public array $groupMapping,
|
||||||
|
public ?string $actorEmail = null,
|
||||||
|
public ?string $actorName = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
*/
|
||||||
|
public function handle(AssignmentRestoreService $assignmentRestoreService): array
|
||||||
|
{
|
||||||
|
$restoreRun = RestoreRun::find($this->restoreRunId);
|
||||||
|
$tenant = Tenant::find($this->tenantId);
|
||||||
|
|
||||||
|
if (! $restoreRun || ! $tenant) {
|
||||||
|
Log::warning('RestoreAssignmentsJob missing context', [
|
||||||
|
'restore_run_id' => $this->restoreRunId,
|
||||||
|
'tenant_id' => $this->tenantId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'outcomes' => [],
|
||||||
|
'summary' => ['success' => 0, 'failed' => 0, 'skipped' => 0],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $assignmentRestoreService->restore(
|
||||||
|
tenant: $tenant,
|
||||||
|
policyType: $this->policyType,
|
||||||
|
policyId: $this->policyId,
|
||||||
|
assignments: $this->assignments,
|
||||||
|
groupMapping: $this->groupMapping,
|
||||||
|
restoreRun: $restoreRun,
|
||||||
|
actorEmail: $this->actorEmail,
|
||||||
|
actorName: $this->actorName,
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('RestoreAssignmentsJob failed', [
|
||||||
|
'restore_run_id' => $this->restoreRunId,
|
||||||
|
'policy_id' => $this->policyId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'outcomes' => [[
|
||||||
|
'status' => 'failed',
|
||||||
|
'reason' => $e->getMessage(),
|
||||||
|
]],
|
||||||
|
'summary' => ['success' => 0, 'failed' => 1, 'skipped' => 0],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -43,12 +43,16 @@ public function hasGroupMapping(): bool
|
|||||||
|
|
||||||
public function getMappedGroupId(string $sourceGroupId): ?string
|
public function getMappedGroupId(string $sourceGroupId): ?string
|
||||||
{
|
{
|
||||||
return $this->group_mapping[$sourceGroupId] ?? null;
|
$mapping = $this->group_mapping ?? [];
|
||||||
|
|
||||||
|
return $mapping[$sourceGroupId] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isGroupSkipped(string $sourceGroupId): bool
|
public function isGroupSkipped(string $sourceGroupId): bool
|
||||||
{
|
{
|
||||||
return $this->group_mapping[$sourceGroupId] === 'SKIP';
|
$mapping = $this->group_mapping ?? [];
|
||||||
|
|
||||||
|
return ($mapping[$sourceGroupId] ?? null) === 'SKIP';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getUnmappedGroupIds(array $sourceGroupIds): array
|
public function getUnmappedGroupIds(array $sourceGroupIds): array
|
||||||
@ -66,7 +70,22 @@ public function addGroupMapping(string $sourceGroupId, string $targetGroupId): v
|
|||||||
// Assignment restore outcome helpers
|
// Assignment restore outcome helpers
|
||||||
public function getAssignmentRestoreOutcomes(): array
|
public function getAssignmentRestoreOutcomes(): array
|
||||||
{
|
{
|
||||||
return $this->results['assignment_outcomes'] ?? [];
|
$results = $this->results ?? [];
|
||||||
|
|
||||||
|
if (isset($results['assignment_outcomes']) && is_array($results['assignment_outcomes'])) {
|
||||||
|
return $results['assignment_outcomes'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($results)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($results)
|
||||||
|
->pluck('assignment_outcomes')
|
||||||
|
->flatten(1)
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSuccessfulAssignmentsCount(): int
|
public function getSuccessfulAssignmentsCount(): int
|
||||||
|
|||||||
358
app/Services/AssignmentRestoreService.php
Normal file
358
app/Services/AssignmentRestoreService.php
Normal file
@ -0,0 +1,358 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\RestoreRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Services\Graph\GraphContractRegistry;
|
||||||
|
use App\Services\Graph\GraphLogger;
|
||||||
|
use App\Services\Graph\GraphResponse;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class AssignmentRestoreService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly GraphClientInterface $graphClient,
|
||||||
|
private readonly GraphContractRegistry $contracts,
|
||||||
|
private readonly GraphLogger $graphLogger,
|
||||||
|
private readonly AuditLogger $auditLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $assignments
|
||||||
|
* @param array<string, string> $groupMapping
|
||||||
|
* @return array{outcomes: array<int, array<string, mixed>>, summary: array{success:int,failed:int,skipped:int}}
|
||||||
|
*/
|
||||||
|
public function restore(
|
||||||
|
Tenant $tenant,
|
||||||
|
string $policyType,
|
||||||
|
string $policyId,
|
||||||
|
array $assignments,
|
||||||
|
array $groupMapping,
|
||||||
|
?RestoreRun $restoreRun = null,
|
||||||
|
?string $actorEmail = null,
|
||||||
|
?string $actorName = null,
|
||||||
|
): array {
|
||||||
|
$outcomes = [];
|
||||||
|
$summary = [
|
||||||
|
'success' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($assignments === []) {
|
||||||
|
return [
|
||||||
|
'outcomes' => $outcomes,
|
||||||
|
'summary' => $summary,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$contract = $this->contracts->get($policyType);
|
||||||
|
$listPath = $this->resolvePath($contract['assignments_list_path'] ?? null, $policyId);
|
||||||
|
$deletePathTemplate = $contract['assignments_delete_path'] ?? null;
|
||||||
|
$createPath = $this->resolvePath($contract['assignments_create_path'] ?? null, $policyId);
|
||||||
|
$createMethod = strtoupper((string) ($contract['assignments_create_method'] ?? 'POST'));
|
||||||
|
|
||||||
|
if (! $listPath || ! $createPath || ! $deletePathTemplate) {
|
||||||
|
$outcomes[] = $this->failureOutcome(null, 'Assignments endpoints are not configured for this policy type.');
|
||||||
|
$summary['failed']++;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'outcomes' => $outcomes,
|
||||||
|
'summary' => $summary,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$graphOptions = $tenant->graphOptions();
|
||||||
|
$tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
|
||||||
|
|
||||||
|
$context = [
|
||||||
|
'tenant' => $tenantIdentifier,
|
||||||
|
'policy_id' => $policyId,
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'restore_run_id' => $restoreRun?->id,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->graphLogger->logRequest('restore_assignments_list', $context + [
|
||||||
|
'method' => 'GET',
|
||||||
|
'endpoint' => $listPath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->graphClient->request('GET', $listPath, $graphOptions);
|
||||||
|
|
||||||
|
$this->graphLogger->logResponse('restore_assignments_list', $response, $context + [
|
||||||
|
'method' => 'GET',
|
||||||
|
'endpoint' => $listPath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$existingAssignments = $response->data['value'] ?? [];
|
||||||
|
|
||||||
|
foreach ($existingAssignments as $existing) {
|
||||||
|
$assignmentId = $existing['id'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($assignmentId) || $assignmentId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$deletePath = $this->resolvePath($deletePathTemplate, $policyId, $assignmentId);
|
||||||
|
|
||||||
|
if (! $deletePath) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->graphLogger->logRequest('restore_assignments_delete', $context + [
|
||||||
|
'method' => 'DELETE',
|
||||||
|
'endpoint' => $deletePath,
|
||||||
|
'assignment_id' => $assignmentId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$deleteResponse = $this->graphClient->request('DELETE', $deletePath, $graphOptions);
|
||||||
|
|
||||||
|
$this->graphLogger->logResponse('restore_assignments_delete', $deleteResponse, $context + [
|
||||||
|
'method' => 'DELETE',
|
||||||
|
'endpoint' => $deletePath,
|
||||||
|
'assignment_id' => $assignmentId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($deleteResponse->failed()) {
|
||||||
|
Log::warning('Failed to delete existing assignment during restore', $context + [
|
||||||
|
'assignment_id' => $assignmentId,
|
||||||
|
'graph_error_message' => $deleteResponse->meta['error_message'] ?? null,
|
||||||
|
'graph_error_code' => $deleteResponse->meta['error_code'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($assignments as $assignment) {
|
||||||
|
if (! is_array($assignment)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupId = $assignment['target']['groupId'] ?? null;
|
||||||
|
$mappedGroupId = $groupId && isset($groupMapping[$groupId]) ? $groupMapping[$groupId] : null;
|
||||||
|
|
||||||
|
if ($mappedGroupId === 'SKIP') {
|
||||||
|
$outcomes[] = $this->skipOutcome($assignment, $groupId, $mappedGroupId);
|
||||||
|
$summary['skipped']++;
|
||||||
|
$this->logAssignmentOutcome(
|
||||||
|
status: 'skipped',
|
||||||
|
tenant: $tenant,
|
||||||
|
assignment: $assignment,
|
||||||
|
restoreRun: $restoreRun,
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
metadata: [
|
||||||
|
'policy_id' => $policyId,
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'group_id' => $groupId,
|
||||||
|
'mapped_group_id' => $mappedGroupId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$assignmentToRestore = $this->applyGroupMapping($assignment, $mappedGroupId);
|
||||||
|
$assignmentToRestore = $this->sanitizeAssignment($assignmentToRestore);
|
||||||
|
|
||||||
|
$this->graphLogger->logRequest('restore_assignments_create', $context + [
|
||||||
|
'method' => $createMethod,
|
||||||
|
'endpoint' => $createPath,
|
||||||
|
'group_id' => $groupId,
|
||||||
|
'mapped_group_id' => $mappedGroupId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$createResponse = $this->graphClient->request($createMethod, $createPath, [
|
||||||
|
'json' => $assignmentToRestore,
|
||||||
|
] + $graphOptions);
|
||||||
|
|
||||||
|
$this->graphLogger->logResponse('restore_assignments_create', $createResponse, $context + [
|
||||||
|
'method' => $createMethod,
|
||||||
|
'endpoint' => $createPath,
|
||||||
|
'group_id' => $groupId,
|
||||||
|
'mapped_group_id' => $mappedGroupId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($createResponse->successful()) {
|
||||||
|
$outcomes[] = $this->successOutcome($assignment, $groupId, $mappedGroupId);
|
||||||
|
$summary['success']++;
|
||||||
|
$this->logAssignmentOutcome(
|
||||||
|
status: 'created',
|
||||||
|
tenant: $tenant,
|
||||||
|
assignment: $assignment,
|
||||||
|
restoreRun: $restoreRun,
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
metadata: [
|
||||||
|
'policy_id' => $policyId,
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'group_id' => $groupId,
|
||||||
|
'mapped_group_id' => $mappedGroupId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$outcomes[] = $this->failureOutcome(
|
||||||
|
$assignment,
|
||||||
|
$createResponse->meta['error_message'] ?? 'Graph create failed',
|
||||||
|
$groupId,
|
||||||
|
$mappedGroupId,
|
||||||
|
$createResponse
|
||||||
|
);
|
||||||
|
$summary['failed']++;
|
||||||
|
$this->logAssignmentOutcome(
|
||||||
|
status: 'failed',
|
||||||
|
tenant: $tenant,
|
||||||
|
assignment: $assignment,
|
||||||
|
restoreRun: $restoreRun,
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
metadata: [
|
||||||
|
'policy_id' => $policyId,
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'group_id' => $groupId,
|
||||||
|
'mapped_group_id' => $mappedGroupId,
|
||||||
|
'graph_error_message' => $createResponse->meta['error_message'] ?? null,
|
||||||
|
'graph_error_code' => $createResponse->meta['error_code'] ?? null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
usleep(100000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'outcomes' => $outcomes,
|
||||||
|
'summary' => $summary,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvePath(?string $template, string $policyId, ?string $assignmentId = null): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($template) || $template === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = str_replace('{id}', urlencode($policyId), $template);
|
||||||
|
|
||||||
|
if ($assignmentId !== null) {
|
||||||
|
$path = str_replace('{assignmentId}', urlencode($assignmentId), $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyGroupMapping(array $assignment, ?string $mappedGroupId): array
|
||||||
|
{
|
||||||
|
if (! $mappedGroupId) {
|
||||||
|
return $assignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = $assignment['target'] ?? [];
|
||||||
|
$odataType = $target['@odata.type'] ?? '';
|
||||||
|
|
||||||
|
if (in_array($odataType, [
|
||||||
|
'#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'#microsoft.graph.exclusionGroupAssignmentTarget',
|
||||||
|
], true) && isset($target['groupId'])) {
|
||||||
|
$target['groupId'] = $mappedGroupId;
|
||||||
|
$assignment['target'] = $target;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $assignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sanitizeAssignment(array $assignment): array
|
||||||
|
{
|
||||||
|
$assignment = Arr::except($assignment, ['id']);
|
||||||
|
$target = $assignment['target'] ?? [];
|
||||||
|
|
||||||
|
unset(
|
||||||
|
$target['group_display_name'],
|
||||||
|
$target['group_orphaned'],
|
||||||
|
$target['assignment_filter_name']
|
||||||
|
);
|
||||||
|
|
||||||
|
$assignment['target'] = $target;
|
||||||
|
|
||||||
|
return $assignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function successOutcome(array $assignment, ?string $groupId, ?string $mappedGroupId): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => 'success',
|
||||||
|
'assignment' => $this->sanitizeAssignment($assignment),
|
||||||
|
'group_id' => $groupId,
|
||||||
|
'mapped_group_id' => $mappedGroupId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function skipOutcome(array $assignment, ?string $groupId, ?string $mappedGroupId): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => 'skipped',
|
||||||
|
'assignment' => $this->sanitizeAssignment($assignment),
|
||||||
|
'group_id' => $groupId,
|
||||||
|
'mapped_group_id' => $mappedGroupId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function failureOutcome(
|
||||||
|
?array $assignment,
|
||||||
|
string $reason,
|
||||||
|
?string $groupId = null,
|
||||||
|
?string $mappedGroupId = null,
|
||||||
|
?GraphResponse $response = null
|
||||||
|
): array {
|
||||||
|
return array_filter([
|
||||||
|
'status' => 'failed',
|
||||||
|
'assignment' => $assignment ? $this->sanitizeAssignment($assignment) : null,
|
||||||
|
'group_id' => $groupId,
|
||||||
|
'mapped_group_id' => $mappedGroupId,
|
||||||
|
'reason' => $reason,
|
||||||
|
'graph_error_message' => $response?->meta['error_message'] ?? null,
|
||||||
|
'graph_error_code' => $response?->meta['error_code'] ?? null,
|
||||||
|
'graph_request_id' => $response?->meta['request_id'] ?? null,
|
||||||
|
'graph_client_request_id' => $response?->meta['client_request_id'] ?? null,
|
||||||
|
], static fn ($value) => $value !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logAssignmentOutcome(
|
||||||
|
string $status,
|
||||||
|
Tenant $tenant,
|
||||||
|
array $assignment,
|
||||||
|
?RestoreRun $restoreRun,
|
||||||
|
?string $actorEmail,
|
||||||
|
?string $actorName,
|
||||||
|
array $metadata
|
||||||
|
): void {
|
||||||
|
$action = match ($status) {
|
||||||
|
'created' => 'restore.assignment.created',
|
||||||
|
'failed' => 'restore.assignment.failed',
|
||||||
|
default => 'restore.assignment.skipped',
|
||||||
|
};
|
||||||
|
|
||||||
|
$statusLabel = match ($status) {
|
||||||
|
'created' => 'success',
|
||||||
|
'failed' => 'failed',
|
||||||
|
default => 'warning',
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: $action,
|
||||||
|
context: [
|
||||||
|
'metadata' => $metadata,
|
||||||
|
'assignment' => $this->sanitizeAssignment($assignment),
|
||||||
|
],
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
status: $statusLabel,
|
||||||
|
resourceType: 'restore_run',
|
||||||
|
resourceId: $restoreRun ? (string) $restoreRun->id : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Services\AssignmentRestoreService;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Graph\GraphContractRegistry;
|
use App\Services\Graph\GraphContractRegistry;
|
||||||
use App\Services\Graph\GraphErrorMapper;
|
use App\Services\Graph\GraphErrorMapper;
|
||||||
@ -25,6 +26,7 @@ public function __construct(
|
|||||||
private readonly VersionService $versionService,
|
private readonly VersionService $versionService,
|
||||||
private readonly SnapshotValidator $snapshotValidator,
|
private readonly SnapshotValidator $snapshotValidator,
|
||||||
private readonly GraphContractRegistry $contracts,
|
private readonly GraphContractRegistry $contracts,
|
||||||
|
private readonly AssignmentRestoreService $assignmentRestoreService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -73,6 +75,7 @@ public function execute(
|
|||||||
bool $dryRun = true,
|
bool $dryRun = true,
|
||||||
?string $actorEmail = null,
|
?string $actorEmail = null,
|
||||||
?string $actorName = null,
|
?string $actorName = null,
|
||||||
|
array $groupMapping = [],
|
||||||
): RestoreRun {
|
): RestoreRun {
|
||||||
$this->assertActiveContext($tenant, $backupSet);
|
$this->assertActiveContext($tenant, $backupSet);
|
||||||
|
|
||||||
@ -90,8 +93,28 @@ public function execute(
|
|||||||
'preview' => $preview,
|
'preview' => $preview,
|
||||||
'started_at' => CarbonImmutable::now(),
|
'started_at' => CarbonImmutable::now(),
|
||||||
'metadata' => [],
|
'metadata' => [],
|
||||||
|
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($groupMapping !== []) {
|
||||||
|
$this->auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'restore.group_mapping.applied',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'restore_run_id' => $restoreRun->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'mapped_groups' => count($groupMapping),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
resourceType: 'restore_run',
|
||||||
|
resourceId: (string) $restoreRun->id,
|
||||||
|
status: 'success'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$results = [];
|
$results = [];
|
||||||
$hardFailures = 0;
|
$hardFailures = 0;
|
||||||
|
|
||||||
@ -265,6 +288,31 @@ public function execute(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$assignmentOutcomes = null;
|
||||||
|
$assignmentSummary = null;
|
||||||
|
|
||||||
|
if (! $dryRun && is_array($item->assignments) && $item->assignments !== []) {
|
||||||
|
$assignmentPolicyId = $createdPolicyId ?? $item->policy_identifier;
|
||||||
|
|
||||||
|
$assignmentOutcomes = $this->assignmentRestoreService->restore(
|
||||||
|
tenant: $tenant,
|
||||||
|
policyType: $item->policy_type,
|
||||||
|
policyId: $assignmentPolicyId,
|
||||||
|
assignments: $item->assignments,
|
||||||
|
groupMapping: $groupMapping,
|
||||||
|
restoreRun: $restoreRun,
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
);
|
||||||
|
|
||||||
|
$assignmentSummary = $assignmentOutcomes['summary'] ?? null;
|
||||||
|
|
||||||
|
if (is_array($assignmentSummary) && ($assignmentSummary['failed'] ?? 0) > 0 && $itemStatus === 'applied') {
|
||||||
|
$itemStatus = 'partial';
|
||||||
|
$resultReason = 'Assignments restored with failures';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$result = $context + ['status' => $itemStatus];
|
$result = $context + ['status' => $itemStatus];
|
||||||
|
|
||||||
if ($settingsApply !== null) {
|
if ($settingsApply !== null) {
|
||||||
@ -285,6 +333,14 @@ public function execute(
|
|||||||
$result['reason'] = 'Some settings require attention';
|
$result['reason'] = 'Some settings require attention';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($assignmentOutcomes !== null) {
|
||||||
|
$result['assignment_outcomes'] = $assignmentOutcomes['outcomes'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($assignmentSummary !== null) {
|
||||||
|
$result['assignment_summary'] = $assignmentSummary;
|
||||||
|
}
|
||||||
|
|
||||||
$results[] = $result;
|
$results[] = $result;
|
||||||
|
|
||||||
$appliedPolicyId = $item->policy_identifier;
|
$appliedPolicyId = $item->policy_identifier;
|
||||||
|
|||||||
250
tests/Feature/RestoreAssignmentApplicationTest.php
Normal file
250
tests/Feature/RestoreAssignmentApplicationTest.php
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Services\Graph\GraphResponse;
|
||||||
|
use App\Services\Intune\RestoreService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
class RestoreAssignmentGraphClient implements GraphClientInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<int, array{method:string,path:string,payload:array|null}>
|
||||||
|
*/
|
||||||
|
public array $requestCalls = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, GraphResponse> $requestResponses
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly GraphResponse $applyPolicyResponse,
|
||||||
|
private array $requestResponses = [],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, ['payload' => []]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrganization(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return $this->applyPolicyResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
$this->requestCalls[] = [
|
||||||
|
'method' => strtoupper($method),
|
||||||
|
'path' => $path,
|
||||||
|
'payload' => $options['json'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
return array_shift($this->requestResponses) ?? new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('restore applies assignments with mapped groups', function () {
|
||||||
|
$applyResponse = new GraphResponse(true, []);
|
||||||
|
$requestResponses = [
|
||||||
|
new GraphResponse(true, ['value' => [['id' => 'assign-old-1']]]), // list
|
||||||
|
new GraphResponse(true, [], 204), // delete
|
||||||
|
new GraphResponse(true, ['id' => 'assign-new-1'], 201), // create 1
|
||||||
|
new GraphResponse(true, ['id' => 'assign-new-2'], 201), // create 2
|
||||||
|
];
|
||||||
|
|
||||||
|
$client = new RestoreAssignmentGraphClient($applyResponse, $requestResponses);
|
||||||
|
app()->instance(GraphClientInterface::class, $client);
|
||||||
|
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-1',
|
||||||
|
'name' => 'Tenant One',
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'scp-1',
|
||||||
|
'policy_type' => 'settingsCatalogPolicy',
|
||||||
|
'display_name' => 'Settings Catalog Alpha',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Backup',
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupItem = BackupItem::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'policy_identifier' => $policy->external_id,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'payload' => ['id' => $policy->external_id, '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy'],
|
||||||
|
'assignments' => [
|
||||||
|
[
|
||||||
|
'id' => 'assignment-1',
|
||||||
|
'intent' => 'apply',
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'groupId' => 'source-group-1',
|
||||||
|
'group_display_name' => 'Source One',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'assignment-2',
|
||||||
|
'intent' => 'apply',
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'groupId' => 'source-group-2',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create(['email' => 'tester@example.com']);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$service = app(RestoreService::class);
|
||||||
|
$run = $service->execute(
|
||||||
|
tenant: $tenant,
|
||||||
|
backupSet: $backupSet,
|
||||||
|
selectedItemIds: [$backupItem->id],
|
||||||
|
dryRun: false,
|
||||||
|
actorEmail: $user->email,
|
||||||
|
actorName: $user->name,
|
||||||
|
groupMapping: [
|
||||||
|
'source-group-1' => 'target-group-1',
|
||||||
|
'source-group-2' => 'target-group-2',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$summary = $run->results[0]['assignment_summary'] ?? null;
|
||||||
|
|
||||||
|
expect($summary)->not->toBeNull();
|
||||||
|
expect($summary['success'])->toBe(2);
|
||||||
|
expect($summary['failed'])->toBe(0);
|
||||||
|
|
||||||
|
$postCalls = collect($client->requestCalls)
|
||||||
|
->filter(fn (array $call) => $call['method'] === 'POST')
|
||||||
|
->values();
|
||||||
|
|
||||||
|
expect($postCalls)->toHaveCount(2);
|
||||||
|
expect($postCalls[0]['payload']['target']['groupId'])->toBe('target-group-1');
|
||||||
|
expect($postCalls[0]['payload'])->not->toHaveKey('id');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore handles assignment failures gracefully', function () {
|
||||||
|
$applyResponse = new GraphResponse(true, []);
|
||||||
|
$requestResponses = [
|
||||||
|
new GraphResponse(true, ['value' => [['id' => 'assign-old-1']]]), // list
|
||||||
|
new GraphResponse(true, [], 204), // delete
|
||||||
|
new GraphResponse(true, ['id' => 'assign-new-1'], 201), // create 1
|
||||||
|
new GraphResponse(false, ['error' => ['message' => 'Bad request']], 400, [
|
||||||
|
['code' => 'BadRequest', 'message' => 'Bad request'],
|
||||||
|
], [], [
|
||||||
|
'error_code' => 'BadRequest',
|
||||||
|
'error_message' => 'Bad request',
|
||||||
|
]), // create 2 fails
|
||||||
|
];
|
||||||
|
|
||||||
|
$client = new RestoreAssignmentGraphClient($applyResponse, $requestResponses);
|
||||||
|
app()->instance(GraphClientInterface::class, $client);
|
||||||
|
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-1',
|
||||||
|
'name' => 'Tenant One',
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'scp-1',
|
||||||
|
'policy_type' => 'settingsCatalogPolicy',
|
||||||
|
'display_name' => 'Settings Catalog Alpha',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Backup',
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupItem = BackupItem::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'policy_identifier' => $policy->external_id,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'payload' => ['id' => $policy->external_id, '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy'],
|
||||||
|
'assignments' => [
|
||||||
|
[
|
||||||
|
'id' => 'assignment-1',
|
||||||
|
'intent' => 'apply',
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'groupId' => 'source-group-1',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'assignment-2',
|
||||||
|
'intent' => 'apply',
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'groupId' => 'source-group-2',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create(['email' => 'tester@example.com']);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$service = app(RestoreService::class);
|
||||||
|
$run = $service->execute(
|
||||||
|
tenant: $tenant,
|
||||||
|
backupSet: $backupSet,
|
||||||
|
selectedItemIds: [$backupItem->id],
|
||||||
|
dryRun: false,
|
||||||
|
actorEmail: $user->email,
|
||||||
|
actorName: $user->name,
|
||||||
|
groupMapping: [
|
||||||
|
'source-group-1' => 'target-group-1',
|
||||||
|
'source-group-2' => 'target-group-2',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$summary = $run->results[0]['assignment_summary'] ?? null;
|
||||||
|
|
||||||
|
expect($summary)->not->toBeNull();
|
||||||
|
expect($summary['success'])->toBe(1);
|
||||||
|
expect($summary['failed'])->toBe(1);
|
||||||
|
expect($run->results[0]['status'])->toBe('partial');
|
||||||
|
});
|
||||||
168
tests/Feature/RestoreGroupMappingTest.php
Normal file
168
tests/Feature/RestoreGroupMappingTest.php
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\RestoreRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Graph\GroupResolver;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('restore wizard shows group mapping for unresolved groups', function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-1',
|
||||||
|
'name' => 'Tenant One',
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'policy-1',
|
||||||
|
'policy_type' => 'settingsCatalogPolicy',
|
||||||
|
'display_name' => 'Settings Catalog',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Backup',
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupItem = BackupItem::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'policy_identifier' => $policy->external_id,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'payload' => ['id' => $policy->external_id],
|
||||||
|
'assignments' => [[
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'groupId' => 'source-group-1',
|
||||||
|
'group_display_name' => 'Source Group',
|
||||||
|
],
|
||||||
|
'intent' => 'apply',
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->mock(GroupResolver::class, function (MockInterface $mock) {
|
||||||
|
$mock->shouldReceive('resolveGroupIds')
|
||||||
|
->andReturnUsing(function (array $groupIds): array {
|
||||||
|
return collect($groupIds)
|
||||||
|
->mapWithKeys(fn (string $id) => [$id => [
|
||||||
|
'id' => $id,
|
||||||
|
'displayName' => null,
|
||||||
|
'orphaned' => true,
|
||||||
|
]])
|
||||||
|
->all();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(CreateRestoreRun::class)
|
||||||
|
->fillForm([
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'backup_item_ids' => [$backupItem->id],
|
||||||
|
])
|
||||||
|
->assertFormFieldVisible('group_mapping.source-group-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore wizard persists group mapping selections', function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-1',
|
||||||
|
'name' => 'Tenant One',
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'policy-1',
|
||||||
|
'policy_type' => 'settingsCatalogPolicy',
|
||||||
|
'display_name' => 'Settings Catalog',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Backup',
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupItem = BackupItem::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'policy_identifier' => $policy->external_id,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'payload' => ['id' => $policy->external_id],
|
||||||
|
'assignments' => [[
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'groupId' => 'source-group-1',
|
||||||
|
'group_display_name' => 'Source Group',
|
||||||
|
],
|
||||||
|
'intent' => 'apply',
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->mock(GroupResolver::class, function (MockInterface $mock) {
|
||||||
|
$mock->shouldReceive('resolveGroupIds')
|
||||||
|
->andReturnUsing(function (array $groupIds): array {
|
||||||
|
return collect($groupIds)
|
||||||
|
->mapWithKeys(function (string $id) {
|
||||||
|
$resolved = $id === 'target-group-1';
|
||||||
|
|
||||||
|
return [$id => [
|
||||||
|
'id' => $id,
|
||||||
|
'displayName' => $resolved ? 'Target Group' : null,
|
||||||
|
'orphaned' => ! $resolved,
|
||||||
|
]];
|
||||||
|
})
|
||||||
|
->all();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(CreateRestoreRun::class)
|
||||||
|
->fillForm([
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'backup_item_ids' => [$backupItem->id],
|
||||||
|
'group_mapping' => [
|
||||||
|
'source-group-1' => 'target-group-1',
|
||||||
|
],
|
||||||
|
'is_dry_run' => true,
|
||||||
|
])
|
||||||
|
->call('create')
|
||||||
|
->assertHasNoFormErrors();
|
||||||
|
|
||||||
|
$run = RestoreRun::first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run->group_mapping)->toBe([
|
||||||
|
'source-group-1' => 'target-group-1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('audit_logs', [
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'action' => 'restore.group_mapping.applied',
|
||||||
|
]);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user