feat/004-assignments-scope-tags #4

Merged
ahmido merged 41 commits from feat/004-assignments-scope-tags into dev 2025-12-23 21:49:59 +00:00
7 changed files with 1152 additions and 3 deletions
Showing only changes of commit cf2aff8188 - Show all commits

View File

@ -7,6 +7,8 @@
use App\Models\BackupSet;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GroupResolver;
use App\Services\Intune\RestoreService;
use BackedEnum;
use Filament\Actions;
@ -15,10 +17,13 @@
use Filament\Infolists;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Str;
use UnitEnum;
class RestoreRunResource extends Resource
@ -54,6 +59,10 @@ public static function form(Schema $schema): Schema
});
})
->reactive()
->afterStateUpdated(function (Set $set): void {
$set('backup_item_ids', []);
$set('group_mapping', []);
})
->required(),
Forms\Components\CheckboxList::make('backup_item_ids')
->label('Items to restore (optional)')
@ -86,7 +95,57 @@ public static function form(Schema $schema): Schema
});
})
->columns(2)
->reactive()
->afterStateUpdated(fn (Set $set) => $set('group_mapping', []))
->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')
->label('Preview only (dry-run)')
->default(true),
@ -233,6 +292,161 @@ public static function createRestoreRun(array $data): RestoreRun
dryRun: (bool) ($data['is_dry_run'] ?? true),
actorEmail: auth()->user()?->email,
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);
}
}

View 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],
];
}
}
}

View File

@ -43,12 +43,16 @@ public function hasGroupMapping(): bool
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
{
return $this->group_mapping[$sourceGroupId] === 'SKIP';
$mapping = $this->group_mapping ?? [];
return ($mapping[$sourceGroupId] ?? null) === 'SKIP';
}
public function getUnmappedGroupIds(array $sourceGroupIds): array
@ -66,7 +70,22 @@ public function addGroupMapping(string $sourceGroupId, string $targetGroupId): v
// Assignment restore outcome helpers
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

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

View File

@ -7,6 +7,7 @@
use App\Models\Policy;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Services\AssignmentRestoreService;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphErrorMapper;
@ -25,6 +26,7 @@ public function __construct(
private readonly VersionService $versionService,
private readonly SnapshotValidator $snapshotValidator,
private readonly GraphContractRegistry $contracts,
private readonly AssignmentRestoreService $assignmentRestoreService,
) {}
/**
@ -73,6 +75,7 @@ public function execute(
bool $dryRun = true,
?string $actorEmail = null,
?string $actorName = null,
array $groupMapping = [],
): RestoreRun {
$this->assertActiveContext($tenant, $backupSet);
@ -90,8 +93,28 @@ public function execute(
'preview' => $preview,
'started_at' => CarbonImmutable::now(),
'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 = [];
$hardFailures = 0;
@ -265,6 +288,31 @@ public function execute(
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];
if ($settingsApply !== null) {
@ -285,6 +333,14 @@ public function execute(
$result['reason'] = 'Some settings require attention';
}
if ($assignmentOutcomes !== null) {
$result['assignment_outcomes'] = $assignmentOutcomes['outcomes'] ?? [];
}
if ($assignmentSummary !== null) {
$result['assignment_summary'] = $assignmentSummary;
}
$results[] = $result;
$appliedPolicyId = $item->policy_identifier;

View 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');
});

View 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',
]);
});