merge: agent session work
This commit is contained in:
commit
f647da8da9
@ -30,6 +30,11 @@ public function table(Table $table): Table
|
||||
->sortable()
|
||||
->searchable()
|
||||
->getStateUsing(fn (BackupItem $record) => $record->resolvedDisplayName()),
|
||||
Tables\Columns\TextColumn::make('policyVersion.version_number')
|
||||
->label('Version')
|
||||
->badge()
|
||||
->default('—')
|
||||
->getStateUsing(fn (BackupItem $record): ?int => $record->policyVersion?->version_number),
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
|
||||
@ -2,7 +2,13 @@
|
||||
|
||||
namespace App\Filament\Resources\PolicyResource\RelationManagers;
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
@ -24,6 +30,55 @@ public function table(Table $table): Table
|
||||
->filters([])
|
||||
->headerActions([])
|
||||
->actions([
|
||||
Actions\Action::make('restore_to_intune')
|
||||
->label('Restore to Intune')
|
||||
->icon('heroicon-o-arrow-path-rounded-square')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
|
||||
->modalSubheading('Creates a restore run using this policy version snapshot.')
|
||||
->form([
|
||||
Forms\Components\Toggle::make('is_dry_run')
|
||||
->label('Preview only (dry-run)')
|
||||
->default(true),
|
||||
])
|
||||
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if ($record->tenant_id !== $tenant->id) {
|
||||
Notification::make()
|
||||
->title('Policy version belongs to a different tenant')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$run = $restoreService->executeFromPolicyVersion(
|
||||
tenant: $tenant,
|
||||
version: $record,
|
||||
dryRun: (bool) ($data['is_dry_run'] ?? true),
|
||||
actorEmail: auth()->user()?->email,
|
||||
actorName: auth()->user()?->name,
|
||||
);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()
|
||||
->title('Restore run failed to start')
|
||||
->body($throwable->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Restore run started')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
return redirect(RestoreRunResource::getUrl('view', ['record' => $run]));
|
||||
}),
|
||||
Actions\ViewAction::make()
|
||||
->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record]))
|
||||
->openUrlInNewTab(false),
|
||||
|
||||
@ -548,7 +548,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
||||
->orWhereDoesntHave('policy')
|
||||
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
|
||||
})
|
||||
->with('policy:id,display_name')
|
||||
->with(['policy:id,display_name', 'policyVersion:id,version_number,captured_at'])
|
||||
->get()
|
||||
->sortBy(function (BackupItem $item) {
|
||||
$meta = static::typeMeta($item->policy_type);
|
||||
@ -570,6 +570,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
||||
$platform = $item->platform ?? $meta['platform'] ?? null;
|
||||
$displayName = $item->resolvedDisplayName();
|
||||
$identifier = $item->policy_identifier ?? null;
|
||||
$versionNumber = $item->policyVersion?->version_number;
|
||||
|
||||
$options[$item->id] = $displayName;
|
||||
|
||||
@ -578,6 +579,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
||||
$typeLabel,
|
||||
$platform,
|
||||
"restore: {$restore}",
|
||||
$versionNumber ? "version: {$versionNumber}" : null,
|
||||
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
|
||||
$identifier ? 'id: '.Str::limit($identifier, 24, '...') : null,
|
||||
]);
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\AssignmentRestoreService;
|
||||
@ -97,6 +98,88 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
|
||||
*
|
||||
* @param array<int>|null $selectedItemIds
|
||||
*/
|
||||
public function executeFromPolicyVersion(
|
||||
Tenant $tenant,
|
||||
PolicyVersion $version,
|
||||
bool $dryRun = true,
|
||||
?string $actorEmail = null,
|
||||
?string $actorName = null,
|
||||
array $groupMapping = [],
|
||||
): RestoreRun {
|
||||
if ($version->tenant_id !== $tenant->id) {
|
||||
throw new \InvalidArgumentException('Policy version does not belong to the provided tenant.');
|
||||
}
|
||||
|
||||
$policy = $version->policy;
|
||||
|
||||
if (! $policy) {
|
||||
throw new \RuntimeException('Policy version has no associated policy.');
|
||||
}
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => sprintf(
|
||||
'Policy Version Restore • %s • v%d',
|
||||
$policy->display_name,
|
||||
$version->version_number
|
||||
),
|
||||
'created_by' => $actorEmail,
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
'completed_at' => CarbonImmutable::now(),
|
||||
'metadata' => [
|
||||
'source' => 'policy_version',
|
||||
'policy_version_id' => $version->id,
|
||||
'policy_version_number' => $version->version_number,
|
||||
'policy_id' => $policy->id,
|
||||
],
|
||||
]);
|
||||
|
||||
$scopeTags = is_array($version->scope_tags) ? $version->scope_tags : [];
|
||||
$scopeTagIds = $scopeTags['ids'] ?? null;
|
||||
$scopeTagNames = $scopeTags['names'] ?? null;
|
||||
|
||||
$backupItemMetadata = [
|
||||
'source' => 'policy_version',
|
||||
'display_name' => $policy->display_name,
|
||||
'policy_version_id' => $version->id,
|
||||
'policy_version_number' => $version->version_number,
|
||||
'version_captured_at' => $version->captured_at?->toIso8601String(),
|
||||
];
|
||||
|
||||
if (is_array($scopeTagIds) && $scopeTagIds !== []) {
|
||||
$backupItemMetadata['scope_tag_ids'] = $scopeTagIds;
|
||||
}
|
||||
|
||||
if (is_array($scopeTagNames) && $scopeTagNames !== []) {
|
||||
$backupItemMetadata['scope_tag_names'] = $scopeTagNames;
|
||||
}
|
||||
|
||||
$backupItem = BackupItem::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'policy_id' => $policy->id,
|
||||
'policy_version_id' => $version->id,
|
||||
'policy_identifier' => $policy->external_id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'captured_at' => $version->captured_at ?? CarbonImmutable::now(),
|
||||
'payload' => $version->snapshot ?? [],
|
||||
'metadata' => $backupItemMetadata,
|
||||
'assignments' => $version->assignments,
|
||||
]);
|
||||
|
||||
return $this->execute(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: [$backupItem->id],
|
||||
dryRun: $dryRun,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
groupMapping: $groupMapping,
|
||||
);
|
||||
}
|
||||
|
||||
public function execute(
|
||||
Tenant $tenant,
|
||||
BackupSet $backupSet,
|
||||
|
||||
@ -14,6 +14,7 @@ ## Phase 3: UI Normalization
|
||||
- [x] T004 Add `GroupPolicyConfigurationNormalizer` and register it (Policy “Normalized settings” is readable).
|
||||
- [x] T009 Make `Normalized diff` labels readable for `groupPolicyConfiguration` and `settingsCatalogPolicy`.
|
||||
- [x] T010 Ensure restore-created versions keep assignments + show scope tags independently.
|
||||
- [x] T012 Add “Restore to Intune” from a specific PolicyVersion (rollback) + show policy version numbers in restore selection UI.
|
||||
|
||||
## Phase 4: Tests + Verification
|
||||
- [x] T005 Add tests for hydration + UI display.
|
||||
|
||||
153
tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php
Normal file
153
tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php
Normal file
@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
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 PolicyVersionRestoreGraphClient implements GraphClientInterface
|
||||
{
|
||||
/**
|
||||
* @var array<int, array{method:string,path:string,payload:array|null}>
|
||||
*/
|
||||
public array $requestCalls = [];
|
||||
|
||||
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 new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
$method = strtoupper($method);
|
||||
|
||||
$this->requestCalls[] = [
|
||||
'method' => $method,
|
||||
'path' => $path,
|
||||
'payload' => $options['json'] ?? null,
|
||||
];
|
||||
|
||||
if ($method === 'GET') {
|
||||
return new GraphResponse(true, ['value' => []]);
|
||||
}
|
||||
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
}
|
||||
|
||||
test('restore can execute from a specific policy version snapshot', function () {
|
||||
$client = new PolicyVersionRestoreGraphClient;
|
||||
app()->instance(GraphClientInterface::class, $client);
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => 'tenant-version-restore',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
$policy = Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'gpo-versioned-1',
|
||||
'policy_type' => 'groupPolicyConfiguration',
|
||||
'display_name' => 'Admin Templates',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$snapshotWithThree = [
|
||||
'id' => $policy->external_id,
|
||||
'displayName' => $policy->display_name,
|
||||
'@odata.type' => '#microsoft.graph.groupPolicyConfiguration',
|
||||
'definitionValues' => collect(range(1, 3))
|
||||
->map(fn (int $i) => [
|
||||
'enabled' => true,
|
||||
'definition@odata.bind' => "https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions('def-{$i}')",
|
||||
'#Definition_Id' => "def-{$i}",
|
||||
'#Definition_displayName' => "Setting {$i}",
|
||||
])->all(),
|
||||
];
|
||||
|
||||
$snapshotWithFive = [
|
||||
'id' => $policy->external_id,
|
||||
'displayName' => $policy->display_name,
|
||||
'@odata.type' => '#microsoft.graph.groupPolicyConfiguration',
|
||||
'definitionValues' => collect(range(1, 5))
|
||||
->map(fn (int $i) => [
|
||||
'enabled' => true,
|
||||
'definition@odata.bind' => "https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions('def-{$i}')",
|
||||
'#Definition_Id' => "def-{$i}",
|
||||
'#Definition_displayName' => "Setting {$i}",
|
||||
])->all(),
|
||||
];
|
||||
|
||||
PolicyVersion::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'policy_id' => $policy->id,
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => now(),
|
||||
'snapshot' => $snapshotWithThree,
|
||||
'metadata' => ['source' => 'version_capture'],
|
||||
]);
|
||||
|
||||
$versionToRestore = PolicyVersion::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'policy_id' => $policy->id,
|
||||
'version_number' => 2,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => now(),
|
||||
'snapshot' => $snapshotWithFive,
|
||||
'metadata' => ['source' => 'version_capture'],
|
||||
]);
|
||||
|
||||
$user = User::factory()->create(['email' => 'tester@example.com']);
|
||||
$this->actingAs($user);
|
||||
|
||||
$service = app(RestoreService::class);
|
||||
$run = $service->executeFromPolicyVersion(
|
||||
tenant: $tenant,
|
||||
version: $versionToRestore,
|
||||
dryRun: false,
|
||||
actorEmail: $user->email,
|
||||
actorName: $user->name,
|
||||
)->refresh();
|
||||
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->results[0]['status'])->toBe('applied');
|
||||
expect($run->results[0]['definition_value_summary']['success'])->toBe(5);
|
||||
|
||||
$definitionValueCreateCalls = collect($client->requestCalls)
|
||||
->filter(fn (array $call) => $call['method'] === 'POST' && str_contains($call['path'], '/definitionValues'))
|
||||
->values();
|
||||
|
||||
expect($definitionValueCreateCalls)->toHaveCount(5);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user