feat(007): device config & compliance snapshot/restore improvements #9
@ -52,16 +52,26 @@ public function table(Table $table): Table
|
||||
->label('Assignments')
|
||||
->badge()
|
||||
->color('info')
|
||||
->getStateUsing(function (BackupItem $record): int {
|
||||
$assignments = $record->policyVersion?->assignments ?? $record->assignments ?? [];
|
||||
->getStateUsing(function (BackupItem $record): string {
|
||||
$assignments = $record->policyVersion?->assignments ?? $record->assignments;
|
||||
|
||||
return is_array($assignments) ? count($assignments) : 0;
|
||||
if (is_array($assignments)) {
|
||||
return (string) count($assignments);
|
||||
}
|
||||
|
||||
$assignmentsFetched = $record->policyVersion?->metadata['assignments_fetched']
|
||||
?? $record->metadata['assignments_fetched']
|
||||
?? false;
|
||||
|
||||
return $assignmentsFetched ? '0' : '—';
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('scope_tags')
|
||||
->label('Scope Tags')
|
||||
->default('—')
|
||||
->getStateUsing(function (BackupItem $record): array {
|
||||
$tags = $record->policyVersion?->scope_tags['names'] ?? [];
|
||||
$tags = $record->policyVersion?->scope_tags['names']
|
||||
?? $record->metadata['scope_tag_names']
|
||||
?? [];
|
||||
|
||||
return is_array($tags) ? $tags : [];
|
||||
})
|
||||
@ -100,6 +110,7 @@ public function table(Table $table): Table
|
||||
|
||||
return Policy::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('ignored_at')
|
||||
->where('last_synced_at', '>', now()->subDays(7)) // Hide deleted policies (Feature 005 workaround)
|
||||
->when($existing, fn (Builder $query) => $query->whereNotIn('id', $existing))
|
||||
->orderBy('display_name')
|
||||
|
||||
@ -543,6 +543,11 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
||||
$items = BackupItem::query()
|
||||
->where('backup_set_id', $backupSetId)
|
||||
->whereHas('backupSet', fn ($query) => $query->where('tenant_id', $tenant->getKey()))
|
||||
->where(function ($query) {
|
||||
$query->whereNull('policy_id')
|
||||
->orWhereDoesntHave('policy')
|
||||
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
|
||||
})
|
||||
->with('policy:id,display_name')
|
||||
->get()
|
||||
->sortBy(function (BackupItem $item) {
|
||||
|
||||
@ -93,12 +93,7 @@ public function enrichWithAssignments(
|
||||
->contains(fn (array $group) => $group['orphaned'] ?? false);
|
||||
}
|
||||
|
||||
$filterIds = collect($assignments)
|
||||
->pluck('target.deviceAndAppManagementAssignmentFilterId')
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
$filterIds = $this->extractAssignmentFilterIds($assignments);
|
||||
|
||||
$filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant);
|
||||
$filterNames = collect($filters)
|
||||
@ -183,9 +178,21 @@ private function enrichAssignments(array $assignments, array $groups, array $fil
|
||||
$target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false;
|
||||
}
|
||||
|
||||
$filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null;
|
||||
if ($filterId && isset($filterNames[$filterId])) {
|
||||
$target['assignment_filter_name'] = $filterNames[$filterId];
|
||||
$filterId = $assignment['deviceAndAppManagementAssignmentFilterId']
|
||||
?? ($target['deviceAndAppManagementAssignmentFilterId'] ?? null);
|
||||
$filterType = $assignment['deviceAndAppManagementAssignmentFilterType']
|
||||
?? ($target['deviceAndAppManagementAssignmentFilterType'] ?? null);
|
||||
|
||||
if ($filterId) {
|
||||
$target['deviceAndAppManagementAssignmentFilterId'] = $target['deviceAndAppManagementAssignmentFilterId'] ?? $filterId;
|
||||
|
||||
if ($filterType) {
|
||||
$target['deviceAndAppManagementAssignmentFilterType'] = $target['deviceAndAppManagementAssignmentFilterType'] ?? $filterType;
|
||||
}
|
||||
|
||||
if (isset($filterNames[$filterId])) {
|
||||
$target['assignment_filter_name'] = $filterNames[$filterId];
|
||||
}
|
||||
}
|
||||
|
||||
$assignment['target'] = $target;
|
||||
@ -193,4 +200,28 @@ private function enrichAssignments(array $assignments, array $groups, array $fil
|
||||
return $assignment;
|
||||
}, $assignments);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $assignments
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function extractAssignmentFilterIds(array $assignments): array
|
||||
{
|
||||
$filterIds = [];
|
||||
|
||||
foreach ($assignments as $assignment) {
|
||||
if (! is_array($assignment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filterId = $assignment['deviceAndAppManagementAssignmentFilterId']
|
||||
?? ($assignment['target']['deviceAndAppManagementAssignmentFilterId'] ?? null);
|
||||
|
||||
if (is_string($filterId) && $filterId !== '') {
|
||||
$filterIds[] = $filterId;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($filterIds));
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\AssignmentFilterResolver;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphContractRegistry;
|
||||
use App\Services\Graph\GraphLogger;
|
||||
@ -19,6 +20,7 @@ public function __construct(
|
||||
private readonly GraphContractRegistry $contracts,
|
||||
private readonly GraphLogger $graphLogger,
|
||||
private readonly AuditLogger $auditLogger,
|
||||
private readonly AssignmentFilterResolver $assignmentFilterResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -56,6 +58,11 @@ public function restore(
|
||||
$createPath = $this->resolvePath($contract['assignments_create_path'] ?? null, $policyId);
|
||||
$createMethod = strtoupper((string) ($contract['assignments_create_method'] ?? 'POST'));
|
||||
$usesAssignAction = is_string($createPath) && str_ends_with($createPath, '/assign');
|
||||
$assignmentsPayloadKey = $contract['assignments_payload_key'] ?? 'assignments';
|
||||
|
||||
if (! is_string($assignmentsPayloadKey) || $assignmentsPayloadKey === '') {
|
||||
$assignmentsPayloadKey = 'assignments';
|
||||
}
|
||||
$listPath = $this->resolvePath($contract['assignments_list_path'] ?? null, $policyId);
|
||||
$deletePathTemplate = $contract['assignments_delete_path'] ?? null;
|
||||
|
||||
@ -84,13 +91,39 @@ public function restore(
|
||||
|
||||
$assignmentFilterMapping = $foundationMapping['assignmentFilter'] ?? [];
|
||||
|
||||
if ($assignmentFilterMapping === []) {
|
||||
$filterIds = $this->extractAssignmentFilterIds($assignments);
|
||||
|
||||
if ($filterIds !== []) {
|
||||
$resolvedFilters = $this->assignmentFilterResolver->resolve($filterIds, $tenant);
|
||||
|
||||
foreach ($resolvedFilters as $filter) {
|
||||
$filterId = $filter['id'] ?? null;
|
||||
|
||||
if (is_string($filterId) && $filterId !== '') {
|
||||
$assignmentFilterMapping[$filterId] = $filterId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($assignments as $assignment) {
|
||||
if (! is_array($assignment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$target = $assignment['target'] ?? [];
|
||||
$filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null;
|
||||
$filterId = $assignment['deviceAndAppManagementAssignmentFilterId']
|
||||
?? ($target['deviceAndAppManagementAssignmentFilterId'] ?? null);
|
||||
$filterLocation = array_key_exists('deviceAndAppManagementAssignmentFilterId', $assignment) ? 'root' : 'target';
|
||||
|
||||
if (! is_string($filterId) && ! is_int($filterId)) {
|
||||
$filterId = null;
|
||||
}
|
||||
|
||||
if (is_string($filterId) && $filterId === '') {
|
||||
$filterId = null;
|
||||
}
|
||||
|
||||
if ($filterId !== null) {
|
||||
if ($assignmentFilterMapping === []) {
|
||||
@ -142,8 +175,12 @@ public function restore(
|
||||
continue;
|
||||
}
|
||||
|
||||
$target['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId;
|
||||
$assignment['target'] = $target;
|
||||
if ($filterLocation === 'root') {
|
||||
$assignment['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId;
|
||||
} else {
|
||||
$target['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId;
|
||||
$assignment['target'] = $target;
|
||||
}
|
||||
}
|
||||
|
||||
$groupId = $assignment['target']['groupId'] ?? null;
|
||||
@ -196,7 +233,7 @@ public function restore(
|
||||
]);
|
||||
|
||||
$assignResponse = $this->graphClient->request($createMethod, $createPath, [
|
||||
'json' => ['assignments' => $preparedAssignments],
|
||||
'json' => [$assignmentsPayloadKey => $preparedAssignments],
|
||||
] + $graphOptions);
|
||||
|
||||
$this->graphLogger->logResponse('restore_assignments_assign', $assignResponse, $context + [
|
||||
@ -413,6 +450,34 @@ private function resolvePath(?string $template, string $policyId, ?string $assig
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $assignments
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function extractAssignmentFilterIds(array $assignments): array
|
||||
{
|
||||
$filterIds = [];
|
||||
|
||||
foreach ($assignments as $assignment) {
|
||||
if (! is_array($assignment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filterId = $assignment['deviceAndAppManagementAssignmentFilterId']
|
||||
?? ($assignment['target']['deviceAndAppManagementAssignmentFilterId'] ?? null);
|
||||
|
||||
if (is_string($filterId) || is_int($filterId)) {
|
||||
$filterId = (string) $filterId;
|
||||
|
||||
if ($filterId !== '') {
|
||||
$filterIds[] = $filterId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($filterIds));
|
||||
}
|
||||
|
||||
private function applyGroupMapping(array $assignment, ?string $mappedGroupId): array
|
||||
{
|
||||
if (! $mappedGroupId) {
|
||||
|
||||
@ -19,84 +19,138 @@ public function __construct(
|
||||
*
|
||||
* @return array Returns assignment array or empty array on failure
|
||||
*/
|
||||
public function fetch(string $policyType, string $tenantId, string $policyId, array $options = []): array
|
||||
{
|
||||
public function fetch(
|
||||
string $policyType,
|
||||
string $tenantId,
|
||||
string $policyId,
|
||||
array $options = [],
|
||||
bool $throwOnFailure = false
|
||||
): array {
|
||||
$contract = $this->contracts->get($policyType);
|
||||
$listPathTemplate = $contract['assignments_list_path'] ?? null;
|
||||
$resource = $contract['resource'] ?? null;
|
||||
$requestOptions = array_merge($options, ['tenant' => $tenantId]);
|
||||
$context = [
|
||||
'tenant_id' => $tenantId,
|
||||
'policy_type' => $policyType,
|
||||
'policy_id' => $policyId,
|
||||
];
|
||||
|
||||
$primaryException = null;
|
||||
$assignments = [];
|
||||
|
||||
// Try primary endpoint
|
||||
try {
|
||||
$contract = $this->contracts->get($policyType);
|
||||
$listPathTemplate = $contract['assignments_list_path'] ?? null;
|
||||
$resource = $contract['resource'] ?? null;
|
||||
$requestOptions = array_merge($options, ['tenant' => $tenantId]);
|
||||
$assignments = $this->fetchPrimary(
|
||||
$listPathTemplate,
|
||||
$policyId,
|
||||
$requestOptions,
|
||||
$context,
|
||||
$throwOnFailure
|
||||
);
|
||||
} catch (GraphException $e) {
|
||||
$primaryException = $e;
|
||||
}
|
||||
|
||||
// Try primary endpoint
|
||||
$assignments = $this->fetchPrimary($listPathTemplate, $policyId, $requestOptions);
|
||||
if (! empty($assignments)) {
|
||||
Log::debug('Fetched assignments via primary endpoint', [
|
||||
'tenant_id' => $tenantId,
|
||||
'policy_type' => $policyType,
|
||||
'policy_id' => $policyId,
|
||||
'count' => count($assignments),
|
||||
]);
|
||||
|
||||
if (! empty($assignments)) {
|
||||
Log::debug('Fetched assignments via primary endpoint', [
|
||||
'tenant_id' => $tenantId,
|
||||
'policy_type' => $policyType,
|
||||
'policy_id' => $policyId,
|
||||
'count' => count($assignments),
|
||||
]);
|
||||
return $assignments;
|
||||
}
|
||||
|
||||
return $assignments;
|
||||
}
|
||||
// Try fallback with $expand
|
||||
Log::debug('Primary endpoint returned empty, trying fallback', [
|
||||
'tenant_id' => $tenantId,
|
||||
'policy_type' => $policyType,
|
||||
'policy_id' => $policyId,
|
||||
]);
|
||||
|
||||
// Try fallback with $expand
|
||||
Log::debug('Primary endpoint returned empty, trying fallback', [
|
||||
if (! is_string($resource) || $resource === '') {
|
||||
Log::debug('Assignments resource not configured for policy type', [
|
||||
'tenant_id' => $tenantId,
|
||||
'policy_type' => $policyType,
|
||||
'policy_id' => $policyId,
|
||||
]);
|
||||
|
||||
if (! is_string($resource) || $resource === '') {
|
||||
Log::debug('Assignments resource not configured for policy type', [
|
||||
if ($throwOnFailure && $primaryException) {
|
||||
Log::warning('Failed to fetch assignments', [
|
||||
'tenant_id' => $tenantId,
|
||||
'policy_type' => $policyType,
|
||||
'policy_id' => $policyId,
|
||||
'error' => $primaryException->getMessage(),
|
||||
'context' => $primaryException->context,
|
||||
]);
|
||||
|
||||
return [];
|
||||
throw $primaryException;
|
||||
}
|
||||
|
||||
$assignments = $this->fetchWithExpand($resource, $policyId, $requestOptions);
|
||||
|
||||
if (! empty($assignments)) {
|
||||
Log::debug('Fetched assignments via fallback endpoint', [
|
||||
'tenant_id' => $tenantId,
|
||||
'policy_type' => $policyType,
|
||||
'policy_id' => $policyId,
|
||||
'count' => count($assignments),
|
||||
]);
|
||||
|
||||
return $assignments;
|
||||
}
|
||||
|
||||
// Both methods returned empty
|
||||
Log::debug('No assignments found for policy', [
|
||||
'tenant_id' => $tenantId,
|
||||
'policy_type' => $policyType,
|
||||
'policy_id' => $policyId,
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$fallbackException = null;
|
||||
|
||||
try {
|
||||
$assignments = $this->fetchWithExpand(
|
||||
$resource,
|
||||
$policyId,
|
||||
$requestOptions,
|
||||
$context,
|
||||
$throwOnFailure
|
||||
);
|
||||
} catch (GraphException $e) {
|
||||
$fallbackException = $e;
|
||||
}
|
||||
|
||||
if (! empty($assignments)) {
|
||||
Log::debug('Fetched assignments via fallback endpoint', [
|
||||
'tenant_id' => $tenantId,
|
||||
'policy_type' => $policyType,
|
||||
'policy_id' => $policyId,
|
||||
'count' => count($assignments),
|
||||
]);
|
||||
|
||||
return $assignments;
|
||||
}
|
||||
|
||||
// Both methods returned empty
|
||||
Log::debug('No assignments found for policy', [
|
||||
'tenant_id' => $tenantId,
|
||||
'policy_type' => $policyType,
|
||||
'policy_id' => $policyId,
|
||||
]);
|
||||
|
||||
if ($throwOnFailure && ($fallbackException || $primaryException)) {
|
||||
$exception = $fallbackException ?? $primaryException;
|
||||
|
||||
Log::warning('Failed to fetch assignments', [
|
||||
'tenant_id' => $tenantId,
|
||||
'policy_type' => $policyType,
|
||||
'policy_id' => $policyId,
|
||||
'error' => $e->getMessage(),
|
||||
'context' => $e->context,
|
||||
'error' => $exception->getMessage(),
|
||||
'context' => $exception->context,
|
||||
]);
|
||||
|
||||
return [];
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch assignments using primary endpoint.
|
||||
*/
|
||||
private function fetchPrimary(?string $listPathTemplate, string $policyId, array $options): array
|
||||
{
|
||||
private function fetchPrimary(
|
||||
?string $listPathTemplate,
|
||||
string $policyId,
|
||||
array $options,
|
||||
array $context,
|
||||
bool $throwOnFailure
|
||||
): array {
|
||||
if (! is_string($listPathTemplate) || $listPathTemplate === '') {
|
||||
return [];
|
||||
}
|
||||
@ -109,14 +163,33 @@ private function fetchPrimary(?string $listPathTemplate, string $policyId, array
|
||||
|
||||
$response = $this->graphClient->request('GET', $path, $options);
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->logAssignmentFailure('primary', $response, $context + ['path' => $path]);
|
||||
|
||||
if ($throwOnFailure) {
|
||||
throw new GraphException(
|
||||
$this->resolveErrorMessage($response),
|
||||
$response->status,
|
||||
$context + ['path' => $path]
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return $response->data['value'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch assignments using $expand fallback.
|
||||
*/
|
||||
private function fetchWithExpand(string $resource, string $policyId, array $options): array
|
||||
{
|
||||
private function fetchWithExpand(
|
||||
string $resource,
|
||||
string $policyId,
|
||||
array $options,
|
||||
array $context,
|
||||
bool $throwOnFailure
|
||||
): array {
|
||||
$path = $resource;
|
||||
$params = [
|
||||
'$expand' => 'assignments',
|
||||
@ -127,6 +200,20 @@ private function fetchWithExpand(string $resource, string $policyId, array $opti
|
||||
'query' => $params,
|
||||
]));
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->logAssignmentFailure('fallback', $response, $context + ['path' => $path]);
|
||||
|
||||
if ($throwOnFailure) {
|
||||
throw new GraphException(
|
||||
$this->resolveErrorMessage($response),
|
||||
$response->status,
|
||||
$context + ['path' => $path]
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$policies = $response->data['value'] ?? [];
|
||||
|
||||
if (empty($policies)) {
|
||||
@ -144,4 +231,32 @@ private function resolvePath(string $template, string $policyId): ?string
|
||||
|
||||
return str_replace('{id}', urlencode($policyId), $template);
|
||||
}
|
||||
|
||||
private function resolveErrorMessage(GraphResponse $response): string
|
||||
{
|
||||
$error = $response->errors[0] ?? null;
|
||||
|
||||
if (is_array($error)) {
|
||||
if (isset($error['message']) && is_string($error['message'])) {
|
||||
return $error['message'];
|
||||
}
|
||||
|
||||
return json_encode($error, JSON_UNESCAPED_SLASHES) ?: 'Graph request failed';
|
||||
}
|
||||
|
||||
if (is_string($error) && $error !== '') {
|
||||
return $error;
|
||||
}
|
||||
|
||||
return 'Graph request failed';
|
||||
}
|
||||
|
||||
private function logAssignmentFailure(string $stage, GraphResponse $response, array $context): void
|
||||
{
|
||||
Log::warning('Assignment fetch failed', $context + [
|
||||
'stage' => $stage,
|
||||
'status' => $response->status,
|
||||
'errors' => $response->errors,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,6 +42,7 @@ public function createBackupSet(
|
||||
$policies = Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereIn('id', $policyIds)
|
||||
->whereNull('ignored_at')
|
||||
->get();
|
||||
|
||||
$backupSet = DB::transaction(function () use ($tenant, $policies, $actorEmail, $name, $includeAssignments, $includeScopeTags, $includeFoundations) {
|
||||
@ -182,6 +183,7 @@ public function addPoliciesToSet(
|
||||
$policies = Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereIn('id', $policyIds)
|
||||
->whereNull('ignored_at')
|
||||
->get();
|
||||
|
||||
$metadata = $backupSet->metadata ?? [];
|
||||
@ -303,6 +305,12 @@ private function snapshotPolicy(
|
||||
$metadata['warnings'] = array_values(array_unique($metadataWarnings));
|
||||
}
|
||||
|
||||
$capturedScopeTags = $captured['scope_tags'] ?? null;
|
||||
if (is_array($capturedScopeTags)) {
|
||||
$metadata['scope_tag_ids'] = $capturedScopeTags['ids'] ?? null;
|
||||
$metadata['scope_tag_names'] = $capturedScopeTags['names'] ?? null;
|
||||
}
|
||||
|
||||
// Create BackupItem as a copy/reference of the PolicyVersion
|
||||
$backupItem = BackupItem::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
|
||||
@ -58,7 +58,15 @@ public function capture(
|
||||
// 2. Fetch assignments if requested
|
||||
if ($includeAssignments) {
|
||||
try {
|
||||
$rawAssignments = $this->assignmentFetcher->fetch($policy->policy_type, $tenantIdentifier, $policy->external_id, $graphOptions);
|
||||
$rawAssignments = $this->assignmentFetcher->fetch(
|
||||
$policy->policy_type,
|
||||
$tenantIdentifier,
|
||||
$policy->external_id,
|
||||
$graphOptions,
|
||||
true
|
||||
);
|
||||
$captureMetadata['assignments_fetched'] = true;
|
||||
$captureMetadata['assignments_count'] = count($rawAssignments);
|
||||
|
||||
if (! empty($rawAssignments)) {
|
||||
$resolvedGroups = [];
|
||||
@ -77,12 +85,7 @@ public function capture(
|
||||
->contains(fn (array $group) => $group['orphaned'] ?? false);
|
||||
}
|
||||
|
||||
$filterIds = collect($rawAssignments)
|
||||
->pluck('target.deviceAndAppManagementAssignmentFilterId')
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
$filterIds = $this->extractAssignmentFilterIds($rawAssignments);
|
||||
|
||||
$filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant);
|
||||
$filterNames = collect($filters)
|
||||
@ -90,7 +93,6 @@ public function capture(
|
||||
->all();
|
||||
|
||||
$assignments = $this->enrichAssignments($rawAssignments, $resolvedGroups, $filterNames);
|
||||
$captureMetadata['assignments_count'] = count($rawAssignments);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$captureMetadata['assignments_fetch_failed'] = true;
|
||||
@ -242,7 +244,15 @@ public function ensureVersionHasAssignments(
|
||||
|
||||
if ($includeAssignments && $version->assignments === null) {
|
||||
try {
|
||||
$rawAssignments = $this->assignmentFetcher->fetch($policy->policy_type, $tenantIdentifier, $policy->external_id, $graphOptions);
|
||||
$rawAssignments = $this->assignmentFetcher->fetch(
|
||||
$policy->policy_type,
|
||||
$tenantIdentifier,
|
||||
$policy->external_id,
|
||||
$graphOptions,
|
||||
true
|
||||
);
|
||||
$metadata['assignments_fetched'] = true;
|
||||
$metadata['assignments_count'] = count($rawAssignments);
|
||||
|
||||
if (! empty($rawAssignments)) {
|
||||
$resolvedGroups = [];
|
||||
@ -261,12 +271,7 @@ public function ensureVersionHasAssignments(
|
||||
->contains(fn (array $group) => $group['orphaned'] ?? false);
|
||||
}
|
||||
|
||||
$filterIds = collect($rawAssignments)
|
||||
->pluck('target.deviceAndAppManagementAssignmentFilterId')
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
$filterIds = $this->extractAssignmentFilterIds($rawAssignments);
|
||||
|
||||
$filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant);
|
||||
$filterNames = collect($filters)
|
||||
@ -274,7 +279,6 @@ public function ensureVersionHasAssignments(
|
||||
->all();
|
||||
|
||||
$assignments = $this->enrichAssignments($rawAssignments, $resolvedGroups, $filterNames);
|
||||
$metadata['assignments_count'] = count($rawAssignments);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$metadata['assignments_fetch_failed'] = true;
|
||||
@ -336,9 +340,21 @@ private function enrichAssignments(array $assignments, array $groups, array $fil
|
||||
$target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false;
|
||||
}
|
||||
|
||||
$filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null;
|
||||
if ($filterId && isset($filterNames[$filterId])) {
|
||||
$target['assignment_filter_name'] = $filterNames[$filterId];
|
||||
$filterId = $assignment['deviceAndAppManagementAssignmentFilterId']
|
||||
?? ($target['deviceAndAppManagementAssignmentFilterId'] ?? null);
|
||||
$filterType = $assignment['deviceAndAppManagementAssignmentFilterType']
|
||||
?? ($target['deviceAndAppManagementAssignmentFilterType'] ?? null);
|
||||
|
||||
if ($filterId) {
|
||||
$target['deviceAndAppManagementAssignmentFilterId'] = $target['deviceAndAppManagementAssignmentFilterId'] ?? $filterId;
|
||||
|
||||
if ($filterType) {
|
||||
$target['deviceAndAppManagementAssignmentFilterType'] = $target['deviceAndAppManagementAssignmentFilterType'] ?? $filterType;
|
||||
}
|
||||
|
||||
if (isset($filterNames[$filterId])) {
|
||||
$target['assignment_filter_name'] = $filterNames[$filterId];
|
||||
}
|
||||
}
|
||||
|
||||
$assignment['target'] = $target;
|
||||
@ -347,6 +363,30 @@ private function enrichAssignments(array $assignments, array $groups, array $fil
|
||||
}, $assignments);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $assignments
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function extractAssignmentFilterIds(array $assignments): array
|
||||
{
|
||||
$filterIds = [];
|
||||
|
||||
foreach ($assignments as $assignment) {
|
||||
if (! is_array($assignment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filterId = $assignment['deviceAndAppManagementAssignmentFilterId']
|
||||
?? ($assignment['target']['deviceAndAppManagementAssignmentFilterId'] ?? null);
|
||||
|
||||
if (is_string($filterId) && $filterId !== '') {
|
||||
$filterIds[] = $filterId;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($filterIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $scopeTagIds
|
||||
* @return array{ids:array<int, string>,names:array<int, string>}
|
||||
|
||||
@ -276,7 +276,7 @@ public function execute(
|
||||
settings: $settings,
|
||||
graphOptions: $graphOptions,
|
||||
context: $context,
|
||||
fallbackName: $item->policy_identifier,
|
||||
fallbackName: $item->resolvedDisplayName(),
|
||||
);
|
||||
|
||||
if ($createOutcome['success']) {
|
||||
@ -385,6 +385,7 @@ public function execute(
|
||||
|
||||
$assignmentOutcomes = null;
|
||||
$assignmentSummary = null;
|
||||
$restoredAssignments = null;
|
||||
|
||||
if (! $dryRun && is_array($item->assignments) && $item->assignments !== []) {
|
||||
$assignmentPolicyId = $createdPolicyId ?? $item->policy_identifier;
|
||||
@ -410,6 +411,19 @@ public function execute(
|
||||
|
||||
}
|
||||
|
||||
if (is_array($assignmentOutcomes)) {
|
||||
$restoredAssignments = collect($assignmentOutcomes['outcomes'] ?? [])
|
||||
->filter(fn (array $outcome) => ($outcome['status'] ?? null) === 'success')
|
||||
->pluck('assignment')
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($restoredAssignments === []) {
|
||||
$restoredAssignments = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_array($complianceActionSummary) && ($complianceActionSummary['skipped'] ?? 0) > 0 && $itemStatus === 'applied') {
|
||||
$itemStatus = 'partial';
|
||||
$resultReason = 'Compliance notification actions skipped';
|
||||
@ -486,7 +500,8 @@ public function execute(
|
||||
'source' => 'restore',
|
||||
'restore_run_id' => $restoreRun->id,
|
||||
'backup_item_id' => $item->id,
|
||||
]
|
||||
],
|
||||
assignments: $restoredAssignments,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1365,6 +1380,11 @@ private function createAutopilotDeploymentProfileIfMissing(
|
||||
$resource = $this->contracts->resourcePath('windowsAutopilotDeploymentProfile')
|
||||
?? 'deviceManagement/windowsAutopilotDeploymentProfiles';
|
||||
$payload = $this->contracts->sanitizeUpdatePayload('windowsAutopilotDeploymentProfile', $originalPayload);
|
||||
$payload['displayName'] = $this->prefixRestoredName(
|
||||
$this->resolvePayloadString($payload, ['displayName', 'name']),
|
||||
$policyId
|
||||
);
|
||||
unset($payload['name']);
|
||||
|
||||
if ($payload === []) {
|
||||
return [
|
||||
@ -1463,7 +1483,7 @@ private function buildSettingsCatalogCreatePayload(
|
||||
$payload = [];
|
||||
$name = $this->resolvePayloadString($originalPayload, ['name', 'displayName']);
|
||||
|
||||
$payload['name'] = $name ?? sprintf('Restored %s', $fallbackName);
|
||||
$payload['name'] = $this->prefixRestoredName($name, $fallbackName);
|
||||
|
||||
$description = $this->resolvePayloadString($originalPayload, ['description', 'Description']);
|
||||
if ($description !== null) {
|
||||
@ -1505,6 +1525,24 @@ private function buildSettingsCatalogCreatePayload(
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function prefixRestoredName(?string $name, string $fallback): string
|
||||
{
|
||||
$prefix = 'Restored_';
|
||||
$base = trim((string) ($name ?? $fallback));
|
||||
|
||||
if ($base === '') {
|
||||
$base = $fallback;
|
||||
}
|
||||
|
||||
$normalized = strtolower($base);
|
||||
|
||||
if (str_starts_with($normalized, 'restored_') || str_starts_with($normalized, 'restored ')) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
return $prefix.$base;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array<int, string> $keys
|
||||
|
||||
@ -91,7 +91,15 @@ public function captureFromGraph(
|
||||
|
||||
if ($includeAssignments) {
|
||||
try {
|
||||
$rawAssignments = $this->assignmentFetcher->fetch($policy->policy_type, $tenantIdentifier, $policy->external_id, $graphOptions);
|
||||
$rawAssignments = $this->assignmentFetcher->fetch(
|
||||
$policy->policy_type,
|
||||
$tenantIdentifier,
|
||||
$policy->external_id,
|
||||
$graphOptions,
|
||||
true
|
||||
);
|
||||
$assignmentMetadata['assignments_fetched'] = true;
|
||||
$assignmentMetadata['assignments_count'] = count($rawAssignments);
|
||||
|
||||
if (! empty($rawAssignments)) {
|
||||
$resolvedGroups = [];
|
||||
@ -110,14 +118,8 @@ public function captureFromGraph(
|
||||
|
||||
$assignmentMetadata['has_orphaned_assignments'] = collect($resolvedGroups)
|
||||
->contains(fn (array $group) => $group['orphaned'] ?? false);
|
||||
$assignmentMetadata['assignments_count'] = count($rawAssignments);
|
||||
|
||||
$filterIds = collect($rawAssignments)
|
||||
->pluck('target.deviceAndAppManagementAssignmentFilterId')
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
$filterIds = $this->extractAssignmentFilterIds($rawAssignments);
|
||||
|
||||
$filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant);
|
||||
$filterNames = collect($filters)
|
||||
@ -170,9 +172,21 @@ private function enrichAssignments(array $assignments, array $groups, array $fil
|
||||
$target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false;
|
||||
}
|
||||
|
||||
$filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null;
|
||||
if ($filterId && isset($filterNames[$filterId])) {
|
||||
$target['assignment_filter_name'] = $filterNames[$filterId];
|
||||
$filterId = $assignment['deviceAndAppManagementAssignmentFilterId']
|
||||
?? ($target['deviceAndAppManagementAssignmentFilterId'] ?? null);
|
||||
$filterType = $assignment['deviceAndAppManagementAssignmentFilterType']
|
||||
?? ($target['deviceAndAppManagementAssignmentFilterType'] ?? null);
|
||||
|
||||
if ($filterId) {
|
||||
$target['deviceAndAppManagementAssignmentFilterId'] = $target['deviceAndAppManagementAssignmentFilterId'] ?? $filterId;
|
||||
|
||||
if ($filterType) {
|
||||
$target['deviceAndAppManagementAssignmentFilterType'] = $target['deviceAndAppManagementAssignmentFilterType'] ?? $filterType;
|
||||
}
|
||||
|
||||
if (isset($filterNames[$filterId])) {
|
||||
$target['assignment_filter_name'] = $filterNames[$filterId];
|
||||
}
|
||||
}
|
||||
|
||||
$assignment['target'] = $target;
|
||||
@ -181,6 +195,30 @@ private function enrichAssignments(array $assignments, array $groups, array $fil
|
||||
}, $assignments);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $assignments
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function extractAssignmentFilterIds(array $assignments): array
|
||||
{
|
||||
$filterIds = [];
|
||||
|
||||
foreach ($assignments as $assignment) {
|
||||
if (! is_array($assignment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filterId = $assignment['deviceAndAppManagementAssignmentFilterId']
|
||||
?? ($assignment['target']['deviceAndAppManagementAssignmentFilterId'] ?? null);
|
||||
|
||||
if (is_string($filterId) && $filterId !== '') {
|
||||
$filterIds[] = $filterId;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($filterIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $scopeTagIds
|
||||
* @return array{ids:array<int, string>,names:array<int, string>}
|
||||
|
||||
@ -196,6 +196,7 @@
|
||||
'assignments_list_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments',
|
||||
'assignments_create_path' => '/deviceManagement/deviceManagementScripts/{id}/assign',
|
||||
'assignments_create_method' => 'POST',
|
||||
'assignments_payload_key' => 'deviceManagementScriptAssignments',
|
||||
'assignments_update_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments/{assignmentId}',
|
||||
'assignments_update_method' => 'PATCH',
|
||||
'assignments_delete_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments/{assignmentId}',
|
||||
@ -215,6 +216,7 @@
|
||||
'assignments_list_path' => '/deviceManagement/deviceShellScripts/{id}/assignments',
|
||||
'assignments_create_path' => '/deviceManagement/deviceShellScripts/{id}/assign',
|
||||
'assignments_create_method' => 'POST',
|
||||
'assignments_payload_key' => 'deviceManagementScriptAssignments',
|
||||
'assignments_update_path' => '/deviceManagement/deviceShellScripts/{id}/assignments/{assignmentId}',
|
||||
'assignments_update_method' => 'PATCH',
|
||||
'assignments_delete_path' => '/deviceManagement/deviceShellScripts/{id}/assignments/{assignmentId}',
|
||||
@ -234,6 +236,7 @@
|
||||
'assignments_list_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments',
|
||||
'assignments_create_path' => '/deviceManagement/deviceHealthScripts/{id}/assign',
|
||||
'assignments_create_method' => 'POST',
|
||||
'assignments_payload_key' => 'deviceHealthScriptAssignments',
|
||||
'assignments_update_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}',
|
||||
'assignments_update_method' => 'PATCH',
|
||||
'assignments_delete_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}',
|
||||
@ -251,6 +254,10 @@
|
||||
'update_method' => 'PATCH',
|
||||
'id_field' => 'id',
|
||||
'hydration' => 'properties',
|
||||
'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments',
|
||||
'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign',
|
||||
'assignments_create_method' => 'POST',
|
||||
'assignments_payload_key' => 'enrollmentConfigurationAssignments',
|
||||
],
|
||||
'windowsAutopilotDeploymentProfile' => [
|
||||
'resource' => 'deviceManagement/windowsAutopilotDeploymentProfiles',
|
||||
@ -290,6 +297,10 @@
|
||||
'update_method' => 'PATCH',
|
||||
'id_field' => 'id',
|
||||
'hydration' => 'properties',
|
||||
'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments',
|
||||
'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign',
|
||||
'assignments_create_method' => 'POST',
|
||||
'assignments_payload_key' => 'enrollmentConfigurationAssignments',
|
||||
],
|
||||
'endpointSecurityIntent' => [
|
||||
'resource' => 'deviceManagement/intents',
|
||||
|
||||
@ -118,9 +118,29 @@
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-950 dark:text-white">
|
||||
Assignments
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Assignments were not captured for this version.
|
||||
</p>
|
||||
@php
|
||||
$assignmentsFetched = $version->metadata['assignments_fetched'] ?? false;
|
||||
$assignmentsFetchFailed = $version->metadata['assignments_fetch_failed'] ?? false;
|
||||
$assignmentsFetchError = $version->metadata['assignments_fetch_error'] ?? null;
|
||||
@endphp
|
||||
@if($assignmentsFetchFailed)
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Assignments could not be fetched from Microsoft Graph.
|
||||
</p>
|
||||
@if($assignmentsFetchError)
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-500">
|
||||
{{ $assignmentsFetchError }}
|
||||
</p>
|
||||
@endif
|
||||
@elseif($assignmentsFetched)
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
No assignments found for this version.
|
||||
</p>
|
||||
@else
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Assignments were not captured for this version.
|
||||
</p>
|
||||
@endif
|
||||
@php
|
||||
$hasBackupItem = $version->policy->backupItems()
|
||||
->whereNotNull('assignments')
|
||||
|
||||
@ -42,9 +42,9 @@ ## Phase 3: Restore Logic and Mapping
|
||||
**Purpose**: Restore new policy types safely using assignment and foundation mappings.
|
||||
|
||||
- [ ] T009 Update `app/Services/Intune/RestoreService.php` to restore the new policy types using Graph contracts.
|
||||
- [ ] T010 Extend `app/Services/AssignmentRestoreService.php` for assignment endpoints of the new types.
|
||||
- [ ] T011 Ensure compliance notification templates are restored and referenced via mapping in `app/Services/Intune/RestoreService.php`.
|
||||
- [ ] T012 Add audit coverage for compliance action mapping outcomes in `app/Services/Intune/AuditLogger.php`.
|
||||
- [x] T010 Extend `app/Services/AssignmentRestoreService.php` for assignment endpoints of the new types.
|
||||
- [x] T011 Ensure compliance notification templates are restored and referenced via mapping in `app/Services/Intune/RestoreService.php`.
|
||||
- [x] T012 Add audit coverage for compliance action mapping outcomes in `app/Services/Intune/AuditLogger.php`.
|
||||
|
||||
**Checkpoint**: Restore applies policies and assignments or skips with clear reasons.
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
// Mock PolicySnapshotService
|
||||
$this->mock(PolicySnapshotService::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('fetch')
|
||||
->twice() // Called once for each policy
|
||||
->once() // Called once for the active policy
|
||||
->andReturnUsing(function ($tenant, $policy) {
|
||||
return [
|
||||
'payload' => [
|
||||
@ -96,6 +96,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
'display_name' => 'Policy B',
|
||||
'platform' => 'windows',
|
||||
'last_synced_at' => now(),
|
||||
'ignored_at' => now(),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
@ -109,15 +110,15 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
'ownerRecord' => $backupSet,
|
||||
'pageClass' => \App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class,
|
||||
])->callTableAction('addPolicies', data: [
|
||||
'policy_ids' => [$policyA->id, $policyB->id],
|
||||
'policy_ids' => [$policyA->id],
|
||||
'include_assignments' => false,
|
||||
'include_scope_tags' => true,
|
||||
]);
|
||||
|
||||
$backupSet->refresh();
|
||||
|
||||
expect($backupSet->item_count)->toBe(2);
|
||||
expect($backupSet->items)->toHaveCount(2);
|
||||
expect($backupSet->item_count)->toBe(1);
|
||||
expect($backupSet->items)->toHaveCount(1);
|
||||
expect($backupSet->items->first()->payload['id'])->toBe('policy-1');
|
||||
|
||||
$firstVersion = PolicyVersion::find($backupSet->items->first()->policy_version_id);
|
||||
@ -140,3 +141,61 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
'resource_id' => (string) $backupSet->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('backup service skips ignored policies', function () {
|
||||
$this->mock(PolicySnapshotService::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('fetch')
|
||||
->once()
|
||||
->andReturnUsing(function ($tenant, $policy) {
|
||||
return [
|
||||
'payload' => [
|
||||
'id' => $policy->external_id,
|
||||
'name' => $policy->display_name,
|
||||
'roleScopeTagIds' => ['0'],
|
||||
],
|
||||
'metadata' => [],
|
||||
'warnings' => [],
|
||||
];
|
||||
});
|
||||
});
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'name' => 'Test tenant',
|
||||
'external_id' => 'tenant-1',
|
||||
'tenant_id' => 'tenant-1',
|
||||
'status' => 'active',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policyA = Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-1',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Policy A',
|
||||
'platform' => 'windows',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
|
||||
$policyB = Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-2',
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'display_name' => 'Policy B',
|
||||
'platform' => 'windows',
|
||||
'last_synced_at' => now(),
|
||||
'ignored_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(\App\Services\Intune\BackupService::class);
|
||||
$backupSet = $service->createBackupSet(
|
||||
tenant: $tenant,
|
||||
policyIds: [$policyA->id, $policyB->id],
|
||||
actorEmail: 'tester@example.com',
|
||||
actorName: 'Tester',
|
||||
);
|
||||
|
||||
expect($backupSet->item_count)->toBe(1);
|
||||
expect($backupSet->items->pluck('policy_id')->all())->toBe([$policyA->id]);
|
||||
});
|
||||
|
||||
@ -292,6 +292,8 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
||||
|
||||
public int $createCalls = 0;
|
||||
|
||||
public array $createPayloads = [];
|
||||
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
@ -326,6 +328,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
{
|
||||
if ($method === 'POST' && str_contains($path, 'windowsAutopilotDeploymentProfiles')) {
|
||||
$this->createCalls++;
|
||||
$this->createPayloads[] = $options['json'] ?? [];
|
||||
|
||||
return new GraphResponse(true, ['id' => 'autopilot-created']);
|
||||
}
|
||||
@ -384,6 +387,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
||||
expect($graphClient->applyCalls)->toBe(1);
|
||||
expect($graphClient->getCalls)->toBe(1);
|
||||
expect($graphClient->createCalls)->toBe(1);
|
||||
expect($graphClient->createPayloads[0]['displayName'] ?? null)->toBe('Restored_Autopilot Profile');
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->results[0]['status'])->toBe('applied');
|
||||
expect($run->results[0]['created_policy_id'])->toBe('autopilot-created');
|
||||
|
||||
@ -22,6 +22,14 @@
|
||||
'display_name' => 'Policy Display',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
$ignoredPolicy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-ignored',
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'display_name' => 'Ignored Policy',
|
||||
'platform' => 'windows',
|
||||
'ignored_at' => now(),
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'item_count' => 2,
|
||||
@ -39,6 +47,18 @@
|
||||
])
|
||||
->create();
|
||||
|
||||
BackupItem::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->state([
|
||||
'policy_id' => $ignoredPolicy->id,
|
||||
'policy_identifier' => $ignoredPolicy->external_id,
|
||||
'policy_type' => $ignoredPolicy->policy_type,
|
||||
'platform' => $ignoredPolicy->platform,
|
||||
'payload' => ['id' => $ignoredPolicy->external_id],
|
||||
])
|
||||
->create();
|
||||
|
||||
BackupItem::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
@ -65,6 +85,7 @@
|
||||
'backup_set_id' => $backupSet->id,
|
||||
])
|
||||
->assertSee('Policy Display')
|
||||
->assertDontSee('Ignored Policy')
|
||||
->assertSee('Scope Tag Alpha')
|
||||
->assertSee('Settings Catalog Policy')
|
||||
->assertSee('Scope Tag')
|
||||
|
||||
@ -536,7 +536,9 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
expect($client->requestCalls[1]['path'])->toBe('deviceManagement/configurationPolicies');
|
||||
expect($client->requestCalls[1]['payload'])->toHaveKey('settings');
|
||||
expect($client->requestCalls[1]['payload'])->toHaveKey('name');
|
||||
expect($client->requestCalls[1]['payload']['name'])->toBe('Restored_Settings Catalog Epsilon');
|
||||
expect($client->requestCalls[2]['path'])->toBe('deviceManagement/configurationPolicies');
|
||||
expect($client->requestCalls[2]['payload'])->not->toHaveKey('settings');
|
||||
expect($client->requestCalls[2]['payload'])->toHaveKey('name');
|
||||
expect($client->requestCalls[2]['payload']['name'])->toBe('Restored_Settings Catalog Epsilon');
|
||||
});
|
||||
|
||||
@ -92,3 +92,23 @@
|
||||
$response->assertOk();
|
||||
$response->assertSee('Assignments were not captured for this version');
|
||||
});
|
||||
|
||||
it('shows empty assignments message when assignments were fetched', function () {
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'policy_id' => $this->policy->id,
|
||||
'version_number' => 1,
|
||||
'assignments' => null,
|
||||
'metadata' => [
|
||||
'assignments_fetched' => true,
|
||||
'assignments_count' => 0,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$response = $this->get("/admin/policy-versions/{$version->id}");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('No assignments found for this version');
|
||||
});
|
||||
|
||||
@ -98,6 +98,70 @@
|
||||
expect($version->assignments[0]['target']['assignment_filter_name'])->toBe('Targeted Devices');
|
||||
});
|
||||
|
||||
it('hydrates assignment filter names when filter data is stored at root', function () {
|
||||
$this->mock(PolicySnapshotService::class, function ($mock) {
|
||||
$mock->shouldReceive('fetch')
|
||||
->once()
|
||||
->andReturn([
|
||||
'payload' => [
|
||||
'id' => 'test-policy-id',
|
||||
'name' => 'Test Policy',
|
||||
'settings' => [],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
$this->mock(AssignmentFetcher::class, function ($mock) {
|
||||
$mock->shouldReceive('fetch')
|
||||
->once()
|
||||
->andReturn([
|
||||
[
|
||||
'id' => 'assignment-1',
|
||||
'intent' => 'apply',
|
||||
'deviceAndAppManagementAssignmentFilterId' => 'filter-123',
|
||||
'deviceAndAppManagementAssignmentFilterType' => 'include',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-123',
|
||||
],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
$this->mock(GroupResolver::class, function ($mock) {
|
||||
$mock->shouldReceive('resolveGroupIds')
|
||||
->once()
|
||||
->andReturn([
|
||||
'group-123' => [
|
||||
'id' => 'group-123',
|
||||
'displayName' => 'Test Group',
|
||||
'orphaned' => false,
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
$this->mock(AssignmentFilterResolver::class, function ($mock) {
|
||||
$mock->shouldReceive('resolve')
|
||||
->once()
|
||||
->andReturn([
|
||||
['id' => 'filter-123', 'displayName' => 'Targeted Devices'],
|
||||
]);
|
||||
});
|
||||
|
||||
$versionService = app(VersionService::class);
|
||||
$version = $versionService->captureFromGraph(
|
||||
$this->tenant,
|
||||
$this->policy,
|
||||
'test@example.com'
|
||||
);
|
||||
|
||||
expect($version->assignments)->not->toBeNull()
|
||||
->and($version->assignments)->toHaveCount(1)
|
||||
->and($version->assignments[0]['target']['deviceAndAppManagementAssignmentFilterId'])->toBe('filter-123')
|
||||
->and($version->assignments[0]['target']['deviceAndAppManagementAssignmentFilterType'])->toBe('include')
|
||||
->and($version->assignments[0]['target']['assignment_filter_name'])->toBe('Targeted Devices');
|
||||
});
|
||||
|
||||
it('captures policy version without assignments when none exist', function () {
|
||||
// Mock dependencies
|
||||
$this->mock(PolicySnapshotService::class, function ($mock) {
|
||||
@ -127,7 +191,9 @@
|
||||
|
||||
expect($version)->not->toBeNull()
|
||||
->and($version->assignments)->toBeNull()
|
||||
->and($version->assignments_hash)->toBeNull();
|
||||
->and($version->assignments_hash)->toBeNull()
|
||||
->and($version->metadata['assignments_fetched'])->toBeTrue()
|
||||
->and($version->metadata['assignments_count'])->toBe(0);
|
||||
});
|
||||
|
||||
it('handles assignment fetch failure gracefully', function () {
|
||||
|
||||
93
tests/Unit/AssignmentBackupServiceTest.php
Normal file
93
tests/Unit/AssignmentBackupServiceTest.php
Normal file
@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\AssignmentBackupService;
|
||||
use App\Services\Graph\AssignmentFetcher;
|
||||
use App\Services\Graph\AssignmentFilterResolver;
|
||||
use App\Services\Graph\GroupResolver;
|
||||
use App\Services\Graph\ScopeTagResolver;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery\MockInterface;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('enriches assignment filter names when filter data is stored at root', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-123',
|
||||
'external_id' => 'tenant-123',
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'metadata' => [],
|
||||
'assignments' => null,
|
||||
]);
|
||||
|
||||
$policyPayload = [
|
||||
'roleScopeTagIds' => ['0'],
|
||||
];
|
||||
|
||||
$this->mock(AssignmentFetcher::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('fetch')
|
||||
->once()
|
||||
->andReturn([
|
||||
[
|
||||
'id' => 'assignment-1',
|
||||
'intent' => 'apply',
|
||||
'deviceAndAppManagementAssignmentFilterId' => 'filter-123',
|
||||
'deviceAndAppManagementAssignmentFilterType' => 'include',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-123',
|
||||
],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
$this->mock(GroupResolver::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('resolveGroupIds')
|
||||
->once()
|
||||
->andReturn([
|
||||
'group-123' => [
|
||||
'id' => 'group-123',
|
||||
'displayName' => 'Test Group',
|
||||
'orphaned' => false,
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
$this->mock(AssignmentFilterResolver::class, function (MockInterface $mock) use ($tenant) {
|
||||
$mock->shouldReceive('resolve')
|
||||
->once()
|
||||
->with(['filter-123'], $tenant)
|
||||
->andReturn([
|
||||
['id' => 'filter-123', 'displayName' => 'Targeted Devices'],
|
||||
]);
|
||||
});
|
||||
|
||||
$this->mock(ScopeTagResolver::class, function (MockInterface $mock) use ($tenant) {
|
||||
$mock->shouldReceive('resolve')
|
||||
->once()
|
||||
->with(['0'], $tenant)
|
||||
->andReturn([
|
||||
['id' => '0', 'displayName' => 'Default'],
|
||||
]);
|
||||
});
|
||||
|
||||
$service = app(AssignmentBackupService::class);
|
||||
$updated = $service->enrichWithAssignments(
|
||||
backupItem: $backupItem,
|
||||
tenant: $tenant,
|
||||
policyType: 'settingsCatalogPolicy',
|
||||
policyId: 'policy-123',
|
||||
policyPayload: $policyPayload,
|
||||
includeAssignments: true
|
||||
);
|
||||
|
||||
expect($updated->assignments)->toHaveCount(1)
|
||||
->and($updated->assignments[0]['target']['deviceAndAppManagementAssignmentFilterId'])->toBe('filter-123')
|
||||
->and($updated->assignments[0]['target']['deviceAndAppManagementAssignmentFilterType'])->toBe('include')
|
||||
->and($updated->assignments[0]['target']['assignment_filter_name'])->toBe('Targeted Devices');
|
||||
});
|
||||
@ -94,7 +94,7 @@
|
||||
|
||||
$this->graphClient
|
||||
->shouldReceive('request')
|
||||
->once()
|
||||
->twice()
|
||||
->andThrow(new GraphException('Graph API error', 500, ['request_id' => 'request-id-123']));
|
||||
|
||||
$result = $this->fetcher->fetch($policyType, $tenantId, $policyId);
|
||||
@ -167,3 +167,30 @@
|
||||
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
|
||||
test('throws when both endpoints fail with throwOnFailure enabled', function () {
|
||||
$tenantId = 'tenant-123';
|
||||
$policyId = 'policy-456';
|
||||
$policyType = 'settingsCatalogPolicy';
|
||||
|
||||
$failureResponse = new GraphResponse(
|
||||
success: false,
|
||||
data: [],
|
||||
status: 403,
|
||||
errors: [['message' => 'Forbidden']]
|
||||
);
|
||||
|
||||
$this->graphClient
|
||||
->shouldReceive('request')
|
||||
->once()
|
||||
->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", Mockery::any())
|
||||
->andReturn($failureResponse);
|
||||
|
||||
$this->graphClient
|
||||
->shouldReceive('request')
|
||||
->once()
|
||||
->with('GET', 'deviceManagement/configurationPolicies', Mockery::any())
|
||||
->andReturn($failureResponse);
|
||||
|
||||
$this->fetcher->fetch($policyType, $tenantId, $policyId, [], true);
|
||||
})->throws(GraphException::class);
|
||||
|
||||
202
tests/Unit/AssignmentRestoreServiceTest.php
Normal file
202
tests/Unit/AssignmentRestoreServiceTest.php
Normal file
@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\AssignmentRestoreService;
|
||||
use App\Services\Graph\AssignmentFilterResolver;
|
||||
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 Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('graph_contracts.types.deviceManagementScript', [
|
||||
'assignments_create_path' => '/deviceManagement/deviceManagementScripts/{id}/assign',
|
||||
'assignments_create_method' => 'POST',
|
||||
'assignments_payload_key' => 'deviceManagementScriptAssignments',
|
||||
]);
|
||||
config()->set('graph_contracts.types.settingsCatalogPolicy', [
|
||||
'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign',
|
||||
'assignments_create_method' => 'POST',
|
||||
]);
|
||||
|
||||
$this->graphClient = Mockery::mock(GraphClientInterface::class);
|
||||
$this->auditLogger = Mockery::mock(AuditLogger::class);
|
||||
$this->filterResolver = Mockery::mock(AssignmentFilterResolver::class);
|
||||
$this->filterResolver->shouldReceive('resolve')->andReturn([])->byDefault();
|
||||
|
||||
$this->service = new AssignmentRestoreService(
|
||||
$this->graphClient,
|
||||
app(GraphContractRegistry::class),
|
||||
app(GraphLogger::class),
|
||||
$this->auditLogger,
|
||||
$this->filterResolver,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the contract assignment payload key for assign actions', function () {
|
||||
$tenant = Tenant::factory()->make([
|
||||
'tenant_id' => 'tenant-123',
|
||||
'app_client_id' => null,
|
||||
'app_client_secret' => null,
|
||||
]);
|
||||
$policyId = 'policy-123';
|
||||
$assignments = [
|
||||
[
|
||||
'id' => 'assignment-1',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-1',
|
||||
],
|
||||
],
|
||||
];
|
||||
$expectedAssignments = [
|
||||
[
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-1',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->graphClient
|
||||
->shouldReceive('request')
|
||||
->once()
|
||||
->with('POST', "/deviceManagement/deviceManagementScripts/{$policyId}/assign", Mockery::on(
|
||||
fn (array $options) => ($options['json']['deviceManagementScriptAssignments'] ?? null) === $expectedAssignments
|
||||
))
|
||||
->andReturn(new GraphResponse(success: true, data: []));
|
||||
|
||||
$this->auditLogger
|
||||
->shouldReceive('log')
|
||||
->once()
|
||||
->andReturn(new AuditLog);
|
||||
|
||||
$result = $this->service->restore(
|
||||
$tenant,
|
||||
'deviceManagementScript',
|
||||
$policyId,
|
||||
$assignments,
|
||||
[]
|
||||
);
|
||||
|
||||
expect($result['summary']['success'])->toBe(1);
|
||||
expect($result['summary']['failed'])->toBe(0);
|
||||
expect($result['summary']['skipped'])->toBe(0);
|
||||
});
|
||||
|
||||
it('maps assignment filter ids stored at the root of assignments', function () {
|
||||
$tenant = Tenant::factory()->make([
|
||||
'tenant_id' => 'tenant-123',
|
||||
'app_client_id' => null,
|
||||
'app_client_secret' => null,
|
||||
]);
|
||||
$policyId = 'policy-789';
|
||||
$assignments = [
|
||||
[
|
||||
'id' => 'assignment-1',
|
||||
'deviceAndAppManagementAssignmentFilterId' => 'filter-source',
|
||||
'deviceAndAppManagementAssignmentFilterType' => 'include',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-1',
|
||||
],
|
||||
],
|
||||
];
|
||||
$expectedAssignments = [
|
||||
[
|
||||
'deviceAndAppManagementAssignmentFilterId' => 'filter-target',
|
||||
'deviceAndAppManagementAssignmentFilterType' => 'include',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-1',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->graphClient
|
||||
->shouldReceive('request')
|
||||
->once()
|
||||
->with('POST', "/deviceManagement/configurationPolicies/{$policyId}/assign", Mockery::on(
|
||||
fn (array $options) => ($options['json']['assignments'] ?? null) === $expectedAssignments
|
||||
))
|
||||
->andReturn(new GraphResponse(success: true, data: []));
|
||||
|
||||
$this->auditLogger
|
||||
->shouldReceive('log')
|
||||
->once()
|
||||
->andReturn(new AuditLog);
|
||||
|
||||
$result = $this->service->restore(
|
||||
$tenant,
|
||||
'settingsCatalogPolicy',
|
||||
$policyId,
|
||||
$assignments,
|
||||
[],
|
||||
[
|
||||
'assignmentFilter' => [
|
||||
'filter-source' => 'filter-target',
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
expect($result['summary']['success'])->toBe(1);
|
||||
expect($result['summary']['failed'])->toBe(0);
|
||||
expect($result['summary']['skipped'])->toBe(0);
|
||||
});
|
||||
|
||||
it('keeps assignment filters when mapping is missing but filter exists in target', function () {
|
||||
$tenant = Tenant::factory()->make([
|
||||
'tenant_id' => 'tenant-123',
|
||||
'app_client_id' => null,
|
||||
'app_client_secret' => null,
|
||||
]);
|
||||
$policyId = 'policy-999';
|
||||
$assignments = [
|
||||
[
|
||||
'id' => 'assignment-1',
|
||||
'deviceAndAppManagementAssignmentFilterId' => 'filter-1',
|
||||
'deviceAndAppManagementAssignmentFilterType' => 'include',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-1',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->filterResolver
|
||||
->shouldReceive('resolve')
|
||||
->once()
|
||||
->with(['filter-1'], $tenant)
|
||||
->andReturn([['id' => 'filter-1', 'displayName' => 'Test']]);
|
||||
|
||||
$this->graphClient
|
||||
->shouldReceive('request')
|
||||
->once()
|
||||
->with('POST', "/deviceManagement/configurationPolicies/{$policyId}/assign", Mockery::on(
|
||||
fn (array $options) => ($options['json']['assignments'][0]['deviceAndAppManagementAssignmentFilterId'] ?? null) === 'filter-1'
|
||||
))
|
||||
->andReturn(new GraphResponse(success: true, data: []));
|
||||
|
||||
$this->auditLogger
|
||||
->shouldReceive('log')
|
||||
->once()
|
||||
->andReturn(new AuditLog);
|
||||
|
||||
$result = $this->service->restore(
|
||||
$tenant,
|
||||
'settingsCatalogPolicy',
|
||||
$policyId,
|
||||
$assignments,
|
||||
[],
|
||||
[]
|
||||
);
|
||||
|
||||
expect($result['summary']['success'])->toBe(1);
|
||||
expect($result['summary']['failed'])->toBe(0);
|
||||
expect($result['summary']['skipped'])->toBe(0);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user