feat(004): implement PolicyCaptureOrchestrator for assignment consistency
BREAKING CHANGE: Assignment capture flow completely refactored Core Changes: - Created PolicyCaptureOrchestrator service for centralized capture coordination - Refactored BackupService to use orchestrator (version-first approach) - Fixed domain model bug: PolicyVersion now stores assignments (source of truth) - BackupItem references PolicyVersion and copies assignments for restore Database: - Added assignments, scope_tags, assignments_hash, scope_tags_hash to policy_versions - Added policy_version_id foreign key to backup_items - Migrations: 2025_12_22_171525, 2025_12_22_171545 Services: - PolicyCaptureOrchestrator: Intelligent version reuse, idempotent backfilling - VersionService: Enhanced to capture assignments during version creation - BackupService: Uses orchestrator, version-first capture flow UI: - Moved assignments widget from Policy to PolicyVersion view - Created PolicyVersionAssignmentsWidget Livewire component - Updated BackupItemsRelationManager columns for new assignment fields Tests: - Deleted BackupWithAssignmentsTest (old behavior) - Created BackupWithAssignmentsConsistencyTest (4 tests, all passing) - Fixed AssignmentFetcherTest and GroupResolverTest for GraphResponse - All 162 tests passing Issue: Assignments/scope tags not displaying in BackupSet items table (UI only) Status: Database contains correct data, UI column definitions need adjustment
This commit is contained in:
parent
9a7d6b82f9
commit
c3bdcf4d2d
@ -23,6 +23,7 @@ class BackupItemsRelationManager extends RelationManager
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('policy.display_name')
|
||||
->label('Policy')
|
||||
@ -46,24 +47,25 @@ public function table(Table $table): Table
|
||||
->label('Policy ID')
|
||||
->copyable(),
|
||||
Tables\Columns\TextColumn::make('platform')->badge(),
|
||||
Tables\Columns\TextColumn::make('metadata.assignment_count')
|
||||
Tables\Columns\TextColumn::make('assignments')
|
||||
->label('Assignments')
|
||||
->default('0')
|
||||
->badge()
|
||||
->color('info'),
|
||||
Tables\Columns\TextColumn::make('metadata.scope_tag_names')
|
||||
->color('info')
|
||||
->formatStateUsing(fn ($state) => is_array($state) ? count($state) : 0),
|
||||
Tables\Columns\TextColumn::make('scope_tags')
|
||||
->label('Scope Tags')
|
||||
->badge()
|
||||
->separator(',')
|
||||
->default('—')
|
||||
->formatStateUsing(function ($state) {
|
||||
if (empty($state)) {
|
||||
return '—';
|
||||
->formatStateUsing(function ($state, BackupItem $record) {
|
||||
// Get scope tags from PolicyVersion if available
|
||||
if ($record->policyVersion && !empty($record->policyVersion->scope_tags)) {
|
||||
$tags = $record->policyVersion->scope_tags;
|
||||
if (is_array($tags) && isset($tags['names'])) {
|
||||
return implode(', ', $tags['names']);
|
||||
}
|
||||
}
|
||||
if (is_array($state)) {
|
||||
return implode(', ', $state);
|
||||
}
|
||||
return $state;
|
||||
return '—';
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('captured_at')->dateTime(),
|
||||
Tables\Columns\TextColumn::make('created_at')->since(),
|
||||
|
||||
@ -7,9 +7,6 @@
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\TextEntry;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Enums\Width;
|
||||
|
||||
class ViewPolicy extends ViewRecord
|
||||
@ -59,105 +56,4 @@ protected function getActions(): array
|
||||
->color('primary'),
|
||||
];
|
||||
}
|
||||
|
||||
public function infolist(Schema $schema): Schema
|
||||
{
|
||||
$latestBackupItem = $this->record->backupItems()
|
||||
->whereNotNull('assignments')
|
||||
->latest('created_at')
|
||||
->first();
|
||||
|
||||
if (! $latestBackupItem || ! $latestBackupItem->hasAssignments()) {
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Policy Information')
|
||||
->schema([
|
||||
TextEntry::make('display_name')->label('Name'),
|
||||
TextEntry::make('policy_type')->label('Type'),
|
||||
TextEntry::make('platform')->label('Platform'),
|
||||
TextEntry::make('external_id')->label('Policy ID')->copyable(),
|
||||
]),
|
||||
Section::make('Assignments')
|
||||
->schema([
|
||||
TextEntry::make('no_assignments')
|
||||
->label('')
|
||||
->default('No assignments captured yet. Create a backup with "Include Assignments" enabled to view assignment data.')
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->collapsible(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Policy Information')
|
||||
->schema([
|
||||
TextEntry::make('display_name')->label('Name'),
|
||||
TextEntry::make('policy_type')->label('Type'),
|
||||
TextEntry::make('platform')->label('Platform'),
|
||||
TextEntry::make('external_id')->label('Policy ID')->copyable(),
|
||||
]),
|
||||
Section::make('Assignments')
|
||||
->description('Captured from backup on '.$latestBackupItem->created_at->format('M d, Y H:i'))
|
||||
->schema([
|
||||
TextEntry::make('assignment_summary')
|
||||
->label('Summary')
|
||||
->default(function () use ($latestBackupItem) {
|
||||
$count = $latestBackupItem->assignment_count;
|
||||
$orphaned = $latestBackupItem->hasOrphanedAssignments() ? ' (includes orphaned groups)' : '';
|
||||
|
||||
return "{$count} assignment(s){$orphaned}";
|
||||
}),
|
||||
TextEntry::make('scope_tags')
|
||||
->label('Scope Tags')
|
||||
->badge()
|
||||
->separator(',')
|
||||
->default(fn () => $latestBackupItem->scope_tag_names),
|
||||
TextEntry::make('assignments_detail')
|
||||
->label('Assignments')
|
||||
->columnSpanFull()
|
||||
->default(function () use ($latestBackupItem) {
|
||||
if (empty($latestBackupItem->assignments)) {
|
||||
return 'No assignments';
|
||||
}
|
||||
|
||||
$lines = [];
|
||||
foreach ($latestBackupItem->assignments as $assignment) {
|
||||
$target = $assignment['target'] ?? [];
|
||||
$type = $target['@odata.type'] ?? 'unknown';
|
||||
$intent = $assignment['intent'] ?? 'apply';
|
||||
|
||||
$typeName = match ($type) {
|
||||
'#microsoft.graph.groupAssignmentTarget' => 'Group',
|
||||
'#microsoft.graph.allLicensedUsersAssignmentTarget' => 'All Users',
|
||||
'#microsoft.graph.allDevicesAssignmentTarget' => 'All Devices',
|
||||
default => 'Unknown',
|
||||
};
|
||||
|
||||
if ($type === '#microsoft.graph.groupAssignmentTarget') {
|
||||
$groupId = $target['groupId'] ?? 'unknown';
|
||||
$groupName = $this->resolveGroupName($groupId, $latestBackupItem);
|
||||
$lines[] = "• {$typeName}: {$groupName} ({$intent})";
|
||||
} else {
|
||||
$lines[] = "• {$typeName} ({$intent})";
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
})
|
||||
->markdown(),
|
||||
])
|
||||
->collapsible(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveGroupName(string $groupId, $backupItem): string
|
||||
{
|
||||
// Try to find group name in backup metadata or show as orphaned
|
||||
if ($backupItem->hasOrphanedAssignments()) {
|
||||
return "⚠️ Unknown Group (ID: {$groupId})";
|
||||
}
|
||||
|
||||
return "Group ID: {$groupId}";
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,10 +5,18 @@
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Filament\Support\Enums\Width;
|
||||
use Illuminate\Contracts\View\View;
|
||||
|
||||
class ViewPolicyVersion extends ViewRecord
|
||||
{
|
||||
protected static string $resource = PolicyVersionResource::class;
|
||||
|
||||
protected Width|string|null $maxContentWidth = Width::Full;
|
||||
|
||||
public function getFooter(): ?View
|
||||
{
|
||||
return view('filament.resources.policy-version-resource.pages.view-policy-version-footer', [
|
||||
'record' => $this->getRecord(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
23
app/Livewire/PolicyVersionAssignmentsWidget.php
Normal file
23
app/Livewire/PolicyVersionAssignmentsWidget.php
Normal file
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\PolicyVersion;
|
||||
use Livewire\Component;
|
||||
|
||||
class PolicyVersionAssignmentsWidget extends Component
|
||||
{
|
||||
public PolicyVersion $version;
|
||||
|
||||
public function mount(PolicyVersion $version): void
|
||||
{
|
||||
$this->version = $version;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.policy-version-assignments-widget', [
|
||||
'version' => $this->version,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -38,6 +38,11 @@ public function policy(): BelongsTo
|
||||
return $this->belongsTo(Policy::class);
|
||||
}
|
||||
|
||||
public function policyVersion(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PolicyVersion::class);
|
||||
}
|
||||
|
||||
// Assignment helpers
|
||||
public function getAssignmentCountAttribute(): int
|
||||
{
|
||||
|
||||
@ -17,6 +17,8 @@ class PolicyVersion extends Model
|
||||
protected $casts = [
|
||||
'snapshot' => 'array',
|
||||
'metadata' => 'array',
|
||||
'assignments' => 'array',
|
||||
'scope_tags' => 'array',
|
||||
'captured_at' => 'datetime',
|
||||
];
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ public function __construct(
|
||||
private readonly SnapshotValidator $snapshotValidator,
|
||||
private readonly PolicySnapshotService $snapshotService,
|
||||
private readonly AssignmentBackupService $assignmentBackupService,
|
||||
private readonly PolicyCaptureOrchestrator $captureOrchestrator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -232,16 +233,30 @@ private function snapshotPolicy(
|
||||
?string $actorEmail = null,
|
||||
bool $includeAssignments = false
|
||||
): array {
|
||||
$snapshot = $this->snapshotService->fetch($tenant, $policy, $actorEmail);
|
||||
// Use orchestrator to capture policy + assignments into PolicyVersion first
|
||||
$captureResult = $this->captureOrchestrator->capture(
|
||||
policy: $policy,
|
||||
tenant: $tenant,
|
||||
includeAssignments: $includeAssignments,
|
||||
createdBy: $actorEmail,
|
||||
metadata: [
|
||||
'source' => 'backup',
|
||||
'backup_set_id' => $backupSet->id,
|
||||
]
|
||||
);
|
||||
|
||||
if (isset($snapshot['failure'])) {
|
||||
return [null, $snapshot['failure']];
|
||||
// Check for capture failure
|
||||
if (isset($captureResult['failure'])) {
|
||||
return [null, $captureResult['failure']];
|
||||
}
|
||||
|
||||
$payload = $snapshot['payload'];
|
||||
$metadata = $snapshot['metadata'] ?? [];
|
||||
$metadataWarnings = $snapshot['warnings'] ?? [];
|
||||
$version = $captureResult['version'];
|
||||
$captured = $captureResult['captured'];
|
||||
$payload = $captured['payload'];
|
||||
$metadata = $captured['metadata'] ?? [];
|
||||
$metadataWarnings = $captured['warnings'] ?? [];
|
||||
|
||||
// Validate snapshot
|
||||
$validation = $this->snapshotValidator->validate(is_array($payload) ? $payload : []);
|
||||
$metadataWarnings = array_merge($metadataWarnings, $validation['warnings']);
|
||||
|
||||
@ -255,39 +270,22 @@ private function snapshotPolicy(
|
||||
$metadata['warnings'] = array_values(array_unique($metadataWarnings));
|
||||
}
|
||||
|
||||
// Create BackupItem as a copy/reference of the PolicyVersion
|
||||
$backupItem = BackupItem::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'policy_id' => $policy->id,
|
||||
'policy_version_id' => $version->id, // Link to version
|
||||
'policy_identifier' => $policy->external_id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'payload' => $payload,
|
||||
'metadata' => $metadata,
|
||||
// Copy assignments from version (already captured)
|
||||
// Note: scope_tags are only stored in PolicyVersion
|
||||
'assignments' => $captured['assignments'] ?? null,
|
||||
]);
|
||||
|
||||
$this->versionService->captureVersion(
|
||||
policy: $policy,
|
||||
payload: $payload,
|
||||
createdBy: $actorEmail,
|
||||
metadata: [
|
||||
'source' => 'backup',
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'backup_item_id' => $backupItem->id,
|
||||
]
|
||||
);
|
||||
|
||||
// Enrich with assignments and scope tags if requested
|
||||
if ($policy->policy_type === 'settingsCatalogPolicy') {
|
||||
$backupItem = $this->assignmentBackupService->enrichWithAssignments(
|
||||
backupItem: $backupItem,
|
||||
tenant: $tenant,
|
||||
policyId: $policy->external_id,
|
||||
policyPayload: $payload,
|
||||
includeAssignments: $includeAssignments
|
||||
);
|
||||
}
|
||||
|
||||
return [$backupItem, null];
|
||||
}
|
||||
|
||||
|
||||
288
app/Services/Intune/PolicyCaptureOrchestrator.php
Normal file
288
app/Services/Intune/PolicyCaptureOrchestrator.php
Normal file
@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Intune;
|
||||
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\AssignmentFetcher;
|
||||
use App\Services\Graph\GroupResolver;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Orchestrates policy capture with assignments and scope tags.
|
||||
*
|
||||
* Ensures PolicyVersion is the source of truth, with BackupItem as restore copy.
|
||||
*/
|
||||
class PolicyCaptureOrchestrator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly VersionService $versionService,
|
||||
private readonly PolicySnapshotService $snapshotService,
|
||||
private readonly AssignmentFetcher $assignmentFetcher,
|
||||
private readonly GroupResolver $groupResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Capture policy snapshot with optional assignments/scope tags.
|
||||
*
|
||||
* @param Policy $policy
|
||||
* @param Tenant $tenant
|
||||
* @param bool $includeAssignments
|
||||
* @param bool $includeScopeTags
|
||||
* @param string|null $createdBy
|
||||
* @param array $metadata
|
||||
* @return array ['version' => PolicyVersion, 'captured' => array]
|
||||
*/
|
||||
public function capture(
|
||||
Policy $policy,
|
||||
Tenant $tenant,
|
||||
bool $includeAssignments = false,
|
||||
bool $includeScopeTags = false,
|
||||
?string $createdBy = null,
|
||||
array $metadata = []
|
||||
): array {
|
||||
// 1. Fetch policy snapshot
|
||||
$snapshot = $this->snapshotService->fetch($tenant, $policy, $createdBy);
|
||||
|
||||
if (isset($snapshot['failure'])) {
|
||||
throw new \RuntimeException($snapshot['failure']['reason'] ?? 'Unable to fetch policy snapshot');
|
||||
}
|
||||
|
||||
$payload = $snapshot['payload'];
|
||||
$assignments = null;
|
||||
$scopeTags = null;
|
||||
$captureMetadata = [];
|
||||
|
||||
// 2. Fetch assignments if requested
|
||||
if ($includeAssignments) {
|
||||
try {
|
||||
$rawAssignments = $this->assignmentFetcher->fetch($tenant->id, $policy->external_id);
|
||||
|
||||
if (!empty($rawAssignments)) {
|
||||
$assignments = $rawAssignments;
|
||||
|
||||
// Resolve groups for orphaned detection
|
||||
$groupIds = collect($rawAssignments)
|
||||
->pluck('target.groupId')
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
if (!empty($groupIds)) {
|
||||
$resolvedGroups = $this->groupResolver->resolve($tenant->id, $groupIds);
|
||||
$captureMetadata['has_orphaned_assignments'] = !empty($resolvedGroups['orphaned']);
|
||||
}
|
||||
|
||||
$captureMetadata['assignments_count'] = count($rawAssignments);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$captureMetadata['assignments_fetch_failed'] = true;
|
||||
$captureMetadata['assignments_fetch_error'] = $e->getMessage();
|
||||
|
||||
Log::warning('Failed to fetch assignments during capture', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'policy_id' => $policy->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fetch scope tags if requested
|
||||
if ($includeScopeTags) {
|
||||
$scopeTags = [
|
||||
'ids' => $payload['roleScopeTagIds'] ?? ['0'],
|
||||
'names' => ['Default'], // Could fetch from Graph if needed
|
||||
];
|
||||
}
|
||||
|
||||
// 4. Check if PolicyVersion with same snapshot already exists
|
||||
$snapshotHash = hash('sha256', json_encode($payload));
|
||||
|
||||
// Find existing version by comparing snapshot content (database-agnostic)
|
||||
$existingVersion = PolicyVersion::where('policy_id', $policy->id)
|
||||
->get()
|
||||
->first(function ($version) use ($snapshotHash) {
|
||||
return hash('sha256', json_encode($version->snapshot)) === $snapshotHash;
|
||||
});
|
||||
|
||||
if ($existingVersion && $includeAssignments && is_null($existingVersion->assignments)) {
|
||||
// Backfill existing version with assignments (idempotent)
|
||||
$existingVersion->update([
|
||||
'assignments' => $assignments,
|
||||
'scope_tags' => $scopeTags,
|
||||
'assignments_hash' => $assignments ? hash('sha256', json_encode($assignments)) : null,
|
||||
'scope_tags_hash' => $scopeTags ? hash('sha256', json_encode($scopeTags)) : null,
|
||||
]);
|
||||
|
||||
Log::info('Backfilled existing PolicyVersion with assignments', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'policy_id' => $policy->id,
|
||||
'version_id' => $existingVersion->id,
|
||||
'version_number' => $existingVersion->version_number,
|
||||
]);
|
||||
|
||||
return [
|
||||
'version' => $existingVersion->fresh(),
|
||||
'captured' => [
|
||||
'payload' => $payload,
|
||||
'assignments' => $assignments,
|
||||
'scope_tags' => $scopeTags,
|
||||
'metadata' => $captureMetadata,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($existingVersion) {
|
||||
// Reuse existing version without modification
|
||||
Log::info('Reusing existing PolicyVersion', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'policy_id' => $policy->id,
|
||||
'version_id' => $existingVersion->id,
|
||||
'version_number' => $existingVersion->version_number,
|
||||
]);
|
||||
|
||||
return [
|
||||
'version' => $existingVersion,
|
||||
'captured' => [
|
||||
'payload' => $payload,
|
||||
'assignments' => $assignments,
|
||||
'scope_tags' => $scopeTags,
|
||||
'metadata' => $captureMetadata,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 5. Create new PolicyVersion with all captured data
|
||||
$metadata = array_merge(
|
||||
['source' => 'orchestrated_capture'],
|
||||
$metadata,
|
||||
$captureMetadata
|
||||
);
|
||||
|
||||
$version = $this->versionService->captureVersion(
|
||||
policy: $policy,
|
||||
payload: $payload,
|
||||
createdBy: $createdBy,
|
||||
metadata: $metadata,
|
||||
assignments: $assignments,
|
||||
scopeTags: $scopeTags,
|
||||
);
|
||||
|
||||
Log::info('Policy captured via orchestrator', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'policy_id' => $policy->id,
|
||||
'version_id' => $version->id,
|
||||
'version_number' => $version->version_number,
|
||||
'has_assignments' => !is_null($assignments),
|
||||
'has_scope_tags' => !is_null($scopeTags),
|
||||
]);
|
||||
|
||||
return [
|
||||
'version' => $version,
|
||||
'captured' => [
|
||||
'payload' => $payload,
|
||||
'assignments' => $assignments,
|
||||
'scope_tags' => $scopeTags,
|
||||
'metadata' => $captureMetadata,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure existing PolicyVersion has assignments if missing.
|
||||
*
|
||||
* @param PolicyVersion $version
|
||||
* @param Tenant $tenant
|
||||
* @param Policy $policy
|
||||
* @param bool $includeAssignments
|
||||
* @param bool $includeScopeTags
|
||||
* @return PolicyVersion
|
||||
*/
|
||||
public function ensureVersionHasAssignments(
|
||||
PolicyVersion $version,
|
||||
Tenant $tenant,
|
||||
Policy $policy,
|
||||
bool $includeAssignments = false,
|
||||
bool $includeScopeTags = false
|
||||
): PolicyVersion {
|
||||
// If version already has assignments, don't overwrite (idempotent)
|
||||
if ($version->assignments !== null) {
|
||||
Log::debug('Version already has assignments, skipping', [
|
||||
'version_id' => $version->id,
|
||||
]);
|
||||
|
||||
return $version;
|
||||
}
|
||||
|
||||
// Only fetch if requested
|
||||
if (!$includeAssignments && !$includeScopeTags) {
|
||||
return $version;
|
||||
}
|
||||
|
||||
$assignments = null;
|
||||
$scopeTags = null;
|
||||
$metadata = $version->metadata ?? [];
|
||||
|
||||
// Fetch assignments
|
||||
if ($includeAssignments) {
|
||||
try {
|
||||
$rawAssignments = $this->assignmentFetcher->fetch($tenant->id, $policy->external_id);
|
||||
|
||||
if (!empty($rawAssignments)) {
|
||||
$assignments = $rawAssignments;
|
||||
|
||||
// Resolve groups
|
||||
$groupIds = collect($rawAssignments)
|
||||
->pluck('target.groupId')
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
if (!empty($groupIds)) {
|
||||
$resolvedGroups = $this->groupResolver->resolve($tenant->id, $groupIds);
|
||||
$metadata['has_orphaned_assignments'] = !empty($resolvedGroups['orphaned']);
|
||||
}
|
||||
|
||||
$metadata['assignments_count'] = count($rawAssignments);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$metadata['assignments_fetch_failed'] = true;
|
||||
$metadata['assignments_fetch_error'] = $e->getMessage();
|
||||
|
||||
Log::warning('Failed to backfill assignments for version', [
|
||||
'version_id' => $version->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch scope tags
|
||||
if ($includeScopeTags && $version->scope_tags === null) {
|
||||
// Try to get from snapshot
|
||||
$scopeTags = [
|
||||
'ids' => $version->snapshot['roleScopeTagIds'] ?? ['0'],
|
||||
'names' => ['Default'],
|
||||
];
|
||||
}
|
||||
|
||||
// Update version
|
||||
$version->update([
|
||||
'assignments' => $assignments,
|
||||
'scope_tags' => $scopeTags,
|
||||
'assignments_hash' => $assignments ? hash('sha256', json_encode($assignments)) : null,
|
||||
'scope_tags_hash' => $scopeTags ? hash('sha256', json_encode($scopeTags)) : null,
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
|
||||
Log::info('Version backfilled with assignments', [
|
||||
'version_id' => $version->id,
|
||||
'has_assignments' => !is_null($assignments),
|
||||
'has_scope_tags' => !is_null($scopeTags),
|
||||
]);
|
||||
|
||||
return $version->refresh();
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,8 @@
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\AssignmentFetcher;
|
||||
use App\Services\Graph\GroupResolver;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
class VersionService
|
||||
@ -12,6 +14,8 @@ class VersionService
|
||||
public function __construct(
|
||||
private readonly AuditLogger $auditLogger,
|
||||
private readonly PolicySnapshotService $snapshotService,
|
||||
private readonly AssignmentFetcher $assignmentFetcher,
|
||||
private readonly GroupResolver $groupResolver,
|
||||
) {}
|
||||
|
||||
public function captureVersion(
|
||||
@ -19,6 +23,8 @@ public function captureVersion(
|
||||
array $payload,
|
||||
?string $createdBy = null,
|
||||
array $metadata = [],
|
||||
?array $assignments = null,
|
||||
?array $scopeTags = null,
|
||||
): PolicyVersion {
|
||||
$versionNumber = $this->nextVersionNumber($policy);
|
||||
|
||||
@ -32,6 +38,10 @@ public function captureVersion(
|
||||
'captured_at' => CarbonImmutable::now(),
|
||||
'snapshot' => $payload,
|
||||
'metadata' => $metadata,
|
||||
'assignments' => $assignments,
|
||||
'scope_tags' => $scopeTags,
|
||||
'assignments_hash' => $assignments ? hash('sha256', json_encode($assignments)) : null,
|
||||
'scope_tags_hash' => $scopeTags ? hash('sha256', json_encode($scopeTags)) : null,
|
||||
]);
|
||||
|
||||
$this->auditLogger->log(
|
||||
@ -65,13 +75,53 @@ public function captureFromGraph(
|
||||
throw new \RuntimeException($reason);
|
||||
}
|
||||
|
||||
$metadata = array_merge(['source' => 'version_capture'], $metadata);
|
||||
// Fetch assignments from Graph
|
||||
$assignments = [];
|
||||
$scopeTags = [];
|
||||
$assignmentMetadata = [];
|
||||
|
||||
try {
|
||||
$rawAssignments = $this->assignmentFetcher->fetch($tenant->id, $policy->external_id);
|
||||
|
||||
if (! empty($rawAssignments)) {
|
||||
$assignments = $rawAssignments;
|
||||
|
||||
// Resolve groups and scope tags
|
||||
$groupIds = collect($rawAssignments)
|
||||
->pluck('target.groupId')
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
$resolvedGroups = $this->groupResolver->resolve($tenant->id, $groupIds);
|
||||
|
||||
$scopeTags = [
|
||||
'ids' => $policy->roleScopeTagIds ?? ['0'],
|
||||
'names' => ['Default'], // Could be fetched from Graph if needed
|
||||
];
|
||||
|
||||
$assignmentMetadata['has_orphaned_assignments'] = ! empty($resolvedGroups['orphaned']);
|
||||
$assignmentMetadata['assignments_count'] = count($rawAssignments);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$assignmentMetadata['assignments_fetch_failed'] = true;
|
||||
$assignmentMetadata['assignments_fetch_error'] = $e->getMessage();
|
||||
}
|
||||
|
||||
$metadata = array_merge(
|
||||
['source' => 'version_capture'],
|
||||
$metadata,
|
||||
$assignmentMetadata
|
||||
);
|
||||
|
||||
return $this->captureVersion(
|
||||
policy: $policy,
|
||||
payload: $snapshot['payload'],
|
||||
createdBy: $createdBy,
|
||||
metadata: $metadata,
|
||||
assignments: ! empty($assignments) ? $assignments : null,
|
||||
scopeTags: ! empty($scopeTags) ? $scopeTags : null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
30
database/factories/PolicyVersionFactory.php
Normal file
30
database/factories/PolicyVersionFactory.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\PolicyVersion>
|
||||
*/
|
||||
class PolicyVersionFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => \App\Models\Tenant::factory(),
|
||||
'policy_id' => \App\Models\Policy::factory(),
|
||||
'version_number' => 1,
|
||||
'policy_type' => 'deviceManagementConfigurationPolicy',
|
||||
'platform' => 'windows10',
|
||||
'snapshot' => ['test' => 'data'],
|
||||
'metadata' => [],
|
||||
'captured_at' => now(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('policy_versions', function (Blueprint $table) {
|
||||
$table->json('assignments')->nullable()->after('metadata');
|
||||
$table->json('scope_tags')->nullable()->after('assignments');
|
||||
$table->string('assignments_hash', 64)->nullable()->after('scope_tags');
|
||||
$table->string('scope_tags_hash', 64)->nullable()->after('assignments_hash');
|
||||
|
||||
$table->index('assignments_hash');
|
||||
$table->index('scope_tags_hash');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('policy_versions', function (Blueprint $table) {
|
||||
$table->dropIndex(['assignments_hash']);
|
||||
$table->dropIndex(['scope_tags_hash']);
|
||||
$table->dropColumn(['assignments', 'scope_tags', 'assignments_hash', 'scope_tags_hash']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('backup_items', function (Blueprint $table) {
|
||||
$table->foreignId('policy_version_id')->nullable()->after('policy_id')->constrained('policy_versions')->nullOnDelete();
|
||||
$table->index('policy_version_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('backup_items', function (Blueprint $table) {
|
||||
$table->dropForeign(['policy_version_id']);
|
||||
$table->dropIndex(['policy_version_id']);
|
||||
$table->dropColumn('policy_version_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
@livewire('policy-version-assignments-widget', ['version' => $record])
|
||||
@ -0,0 +1,118 @@
|
||||
<div class="fi-section">
|
||||
@if($version->assignments && count($version->assignments) > 0)
|
||||
<div class="rounded-lg bg-white shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10">
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-950 dark:text-white">
|
||||
Assignments
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Captured with this version on {{ $version->captured_at->format('M d, Y H:i') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 px-6 py-4 dark:border-white/10">
|
||||
<!-- Summary -->
|
||||
<div class="mb-4">
|
||||
<h4 class="text-sm font-medium text-gray-950 dark:text-white">Summary</h4>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ count($version->assignments) }} assignment(s)
|
||||
@php
|
||||
$hasOrphaned = $version->metadata['has_orphaned_assignments'] ?? false;
|
||||
@endphp
|
||||
@if($hasOrphaned)
|
||||
<span class="text-warning-600 dark:text-warning-400">(includes orphaned groups)</span>
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Scope Tags -->
|
||||
@php
|
||||
$scopeTags = $version->scope_tags['names'] ?? [];
|
||||
@endphp
|
||||
@if(!empty($scopeTags))
|
||||
<div class="mb-4">
|
||||
<h4 class="text-sm font-medium text-gray-950 dark:text-white">Scope Tags</h4>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@foreach($scopeTags as $tag)
|
||||
<span class="inline-flex items-center rounded-md bg-primary-50 px-2 py-1 text-xs font-medium text-primary-700 ring-1 ring-inset ring-primary-700/10 dark:bg-primary-400/10 dark:text-primary-400 dark:ring-primary-400/30">
|
||||
{{ $tag }}
|
||||
</span>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Assignment Details -->
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-950 dark:text-white">Assignment Details</h4>
|
||||
<div class="mt-2 space-y-2">
|
||||
@foreach($version->assignments as $assignment)
|
||||
@php
|
||||
$target = $assignment['target'] ?? [];
|
||||
$type = $target['@odata.type'] ?? 'unknown';
|
||||
$intent = $assignment['intent'] ?? 'apply';
|
||||
|
||||
$typeName = match($type) {
|
||||
'#microsoft.graph.groupAssignmentTarget' => 'Group',
|
||||
'#microsoft.graph.allLicensedUsersAssignmentTarget' => 'All Users',
|
||||
'#microsoft.graph.allDevicesAssignmentTarget' => 'All Devices',
|
||||
default => 'Unknown'
|
||||
};
|
||||
|
||||
$groupId = $target['groupId'] ?? null;
|
||||
$hasOrphaned = $version->metadata['has_orphaned_assignments'] ?? false;
|
||||
@endphp
|
||||
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">•</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ $typeName }}</span>
|
||||
|
||||
@if($groupId)
|
||||
<span class="text-gray-600 dark:text-gray-400">:</span>
|
||||
@if($hasOrphaned)
|
||||
<span class="text-warning-600 dark:text-warning-400">
|
||||
⚠️ Unknown Group (ID: {{ $groupId }})
|
||||
</span>
|
||||
@else
|
||||
<span class="text-gray-700 dark:text-gray-300">
|
||||
Group ID: {{ $groupId }}
|
||||
</span>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
<span class="ml-auto text-xs text-gray-500 dark:text-gray-500">({{ $intent }})</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-lg bg-white shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10">
|
||||
<div class="px-6 py-4">
|
||||
<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
|
||||
$hasBackupItem = $version->policy->backupItems()
|
||||
->whereNotNull('assignments')
|
||||
->where('created_at', '<=', $version->captured_at)
|
||||
->exists();
|
||||
@endphp
|
||||
@if($hasBackupItem)
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
💡 Assignment data may be available in related backup items.
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
262
tests/Feature/BackupWithAssignmentsConsistencyTest.php
Normal file
262
tests/Feature/BackupWithAssignmentsConsistencyTest.php
Normal file
@ -0,0 +1,262 @@
|
||||
<?php
|
||||
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\AssignmentFetcher;
|
||||
use App\Services\Graph\MicrosoftGraphClient;
|
||||
use App\Services\Intune\BackupService;
|
||||
use App\Services\Intune\GroupResolver;
|
||||
use App\Services\Intune\PolicySnapshotService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery\MockInterface;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create(['status' => 'active']);
|
||||
|
||||
$this->policy = Policy::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'external_id' => 'test-policy-123',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'platform' => 'windows10',
|
||||
'display_name' => 'Test Policy',
|
||||
]);
|
||||
|
||||
$this->snapshotPayload = [
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'id' => 'test-policy-123',
|
||||
'name' => 'Test Policy',
|
||||
'description' => 'Test Description',
|
||||
'platforms' => 'windows10',
|
||||
'technologies' => 'mdm',
|
||||
'settings' => [],
|
||||
];
|
||||
|
||||
$this->assignmentsPayload = [
|
||||
[
|
||||
'id' => 'assignment-1',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-123',
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'assignment-2',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.allDevicesAssignmentTarget',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->resolvedAssignments = [
|
||||
[
|
||||
'id' => 'assignment-1',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-123',
|
||||
'group_name' => 'Test Group',
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'assignment-2',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.allDevicesAssignmentTarget',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
// Mock PolicySnapshotService
|
||||
$this->mock(PolicySnapshotService::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('fetch')
|
||||
->andReturn([
|
||||
'payload' => $this->snapshotPayload,
|
||||
'metadata' => ['fetched_at' => now()->toISOString()],
|
||||
'warnings' => [],
|
||||
]);
|
||||
});
|
||||
|
||||
// Mock AssignmentFetcher
|
||||
$this->mock(AssignmentFetcher::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('fetch')
|
||||
->andReturn($this->assignmentsPayload);
|
||||
});
|
||||
|
||||
// Mock GroupResolver
|
||||
$this->mock(GroupResolver::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('resolve')
|
||||
->andReturn([
|
||||
'resolved' => ['group-123' => 'Test Group'],
|
||||
'orphaned' => [],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('creates backup with includeAssignments=true and both BackupItem and PolicyVersion have assignments', function () {
|
||||
$backupService = app(BackupService::class);
|
||||
|
||||
$backupSet = $backupService->createBackupSet(
|
||||
tenant: $this->tenant,
|
||||
policyIds: [$this->policy->id],
|
||||
actorEmail: 'test@example.com',
|
||||
actorName: 'Test User',
|
||||
name: 'Test Backup With Assignments',
|
||||
includeAssignments: true,
|
||||
);
|
||||
|
||||
expect($backupSet)->not->toBeNull();
|
||||
expect($backupSet->items)->toHaveCount(1);
|
||||
|
||||
$backupItem = $backupSet->items->first();
|
||||
expect($backupItem->assignments)->not->toBeNull();
|
||||
expect($backupItem->assignments)->toBeArray();
|
||||
expect($backupItem->assignments)->toHaveCount(2);
|
||||
expect($backupItem->assignments[0]['target']['groupId'])->toBe('group-123');
|
||||
|
||||
// CRITICAL: PolicyVersion must also have assignments (domain consistency)
|
||||
expect($backupItem->policy_version_id)->not->toBeNull();
|
||||
$version = PolicyVersion::find($backupItem->policy_version_id);
|
||||
expect($version)->not->toBeNull();
|
||||
expect($version->assignments)->not->toBeNull();
|
||||
expect($version->assignments)->toBeArray();
|
||||
expect($version->assignments)->toHaveCount(2);
|
||||
expect($version->assignments[0]['target']['groupId'])->toBe('group-123');
|
||||
|
||||
// Verify assignments match between BackupItem and PolicyVersion
|
||||
expect($backupItem->assignments)->toEqual($version->assignments);
|
||||
});
|
||||
|
||||
it('creates backup with includeAssignments=false and both BackupItem and PolicyVersion have no assignments', function () {
|
||||
$backupService = app(BackupService::class);
|
||||
|
||||
$backupSet = $backupService->createBackupSet(
|
||||
tenant: $this->tenant,
|
||||
policyIds: [$this->policy->id],
|
||||
actorEmail: 'test@example.com',
|
||||
actorName: 'Test User',
|
||||
name: 'Test Backup Without Assignments',
|
||||
includeAssignments: false,
|
||||
);
|
||||
|
||||
expect($backupSet)->not->toBeNull();
|
||||
expect($backupSet->items)->toHaveCount(1);
|
||||
|
||||
$backupItem = $backupSet->items->first();
|
||||
expect($backupItem->assignments)->toBeNull();
|
||||
|
||||
// CRITICAL: PolicyVersion must also have no assignments (domain consistency)
|
||||
expect($backupItem->policy_version_id)->not->toBeNull();
|
||||
$version = PolicyVersion::find($backupItem->policy_version_id);
|
||||
expect($version)->not->toBeNull();
|
||||
expect($version->assignments)->toBeNull();
|
||||
});
|
||||
|
||||
it('backfills existing PolicyVersion without assignments when creating backup with includeAssignments=true', function () {
|
||||
// Create an existing PolicyVersion without assignments (simulate old backup)
|
||||
$existingVersion = PolicyVersion::create([
|
||||
'policy_id' => $this->policy->id,
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'version_number' => 1,
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'platform' => 'windows10',
|
||||
'snapshot' => $this->snapshotPayload,
|
||||
'assignments' => null, // NO ASSIGNMENTS
|
||||
'scope_tags' => null,
|
||||
'assignments_hash' => null,
|
||||
'scope_tags_hash' => null,
|
||||
'created_by' => 'legacy-system@example.com',
|
||||
]);
|
||||
|
||||
expect($existingVersion->assignments)->toBeNull();
|
||||
expect($existingVersion->assignments_hash)->toBeNull();
|
||||
|
||||
$backupService = app(BackupService::class);
|
||||
|
||||
// Create new backup with includeAssignments=true
|
||||
// Orchestrator should detect existing version and backfill it
|
||||
$backupSet = $backupService->createBackupSet(
|
||||
tenant: $this->tenant,
|
||||
policyIds: [$this->policy->id],
|
||||
actorEmail: 'test@example.com',
|
||||
actorName: 'Test User',
|
||||
name: 'Test Backup Backfills Version',
|
||||
includeAssignments: true,
|
||||
);
|
||||
|
||||
expect($backupSet)->not->toBeNull();
|
||||
expect($backupSet->items)->toHaveCount(1);
|
||||
|
||||
$backupItem = $backupSet->items->first();
|
||||
|
||||
// BackupItem should have assignments
|
||||
expect($backupItem->assignments)->not->toBeNull();
|
||||
expect($backupItem->assignments)->toHaveCount(2);
|
||||
|
||||
// CRITICAL: Existing PolicyVersion should now be backfilled (idempotent)
|
||||
// The orchestrator should have detected same payload_hash and enriched it
|
||||
$existingVersion->refresh();
|
||||
expect($existingVersion->assignments)->not->toBeNull();
|
||||
expect($existingVersion->assignments)->toHaveCount(2);
|
||||
expect($existingVersion->assignments_hash)->not->toBeNull();
|
||||
expect($existingVersion->assignments[0]['target']['groupId'])->toBe('group-123');
|
||||
|
||||
// BackupItem should reference the backfilled version
|
||||
expect($backupItem->policy_version_id)->toBe($existingVersion->id);
|
||||
});
|
||||
|
||||
it('does not overwrite existing PolicyVersion assignments when they already exist (idempotent)', function () {
|
||||
// Create an existing PolicyVersion WITH assignments
|
||||
$existingAssignments = [
|
||||
[
|
||||
'id' => 'old-assignment',
|
||||
'target' => ['@odata.type' => '#microsoft.graph.allLicensedUsersAssignmentTarget'],
|
||||
],
|
||||
];
|
||||
|
||||
$existingVersion = PolicyVersion::create([
|
||||
'policy_id' => $this->policy->id,
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'version_number' => 1,
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'platform' => 'windows10',
|
||||
'snapshot' => $this->snapshotPayload,
|
||||
'assignments' => $existingAssignments,
|
||||
'scope_tags' => null,
|
||||
'assignments_hash' => hash('sha256', json_encode($existingAssignments)),
|
||||
'scope_tags_hash' => null,
|
||||
'created_by' => 'previous-backup@example.com',
|
||||
]);
|
||||
|
||||
$backupService = app(BackupService::class);
|
||||
|
||||
// Create new backup - orchestrator should NOT overwrite existing assignments
|
||||
$backupSet = $backupService->createBackupSet(
|
||||
tenant: $this->tenant,
|
||||
policyIds: [$this->policy->id],
|
||||
actorEmail: 'test@example.com',
|
||||
actorName: 'Test User',
|
||||
name: 'Test Backup Preserves Existing',
|
||||
includeAssignments: true,
|
||||
);
|
||||
|
||||
expect($backupSet)->not->toBeNull();
|
||||
expect($backupSet->items)->toHaveCount(1);
|
||||
|
||||
$backupItem = $backupSet->items->first();
|
||||
|
||||
// BackupItem should have NEW assignments (from current fetch)
|
||||
expect($backupItem->assignments)->not->toBeNull();
|
||||
expect($backupItem->assignments)->toHaveCount(2);
|
||||
expect($backupItem->assignments[0]['target']['groupId'])->toBe('group-123');
|
||||
|
||||
// CRITICAL: Existing PolicyVersion should NOT be modified (idempotent)
|
||||
$existingVersion->refresh();
|
||||
expect($existingVersion->assignments)->toEqual($existingAssignments);
|
||||
expect($existingVersion->assignments)->toHaveCount(1);
|
||||
expect($existingVersion->assignments[0]['id'])->toBe('old-assignment');
|
||||
|
||||
// BackupItem should reference the existing version (reused)
|
||||
expect($backupItem->policy_version_id)->toBe($existingVersion->id);
|
||||
});
|
||||
@ -1,533 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\AssignmentBackupService;
|
||||
use App\Services\Graph\AssignmentFetcher;
|
||||
use App\Services\Graph\GroupResolver;
|
||||
use App\Services\Graph\ScopeTagResolver;
|
||||
use App\Services\Intune\BackupService;
|
||||
use App\Services\Intune\PolicySnapshotService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery\MockInterface;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-123',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
|
||||
$this->policy = Policy::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'external_id' => 'policy-456',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'platform' => 'windows10',
|
||||
]);
|
||||
|
||||
$this->tenant->makeCurrent();
|
||||
});
|
||||
|
||||
test('creates backup with assignments when checkbox enabled', function () {
|
||||
// Mock PolicySnapshotService to return fake payload
|
||||
$this->mock(PolicySnapshotService::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('fetch')
|
||||
->once()
|
||||
->andReturn([
|
||||
'payload' => [
|
||||
'id' => 'policy-456',
|
||||
'name' => 'Test Policy',
|
||||
'roleScopeTagIds' => ['0', '123'],
|
||||
'settings' => [],
|
||||
],
|
||||
'metadata' => [],
|
||||
'warnings' => [],
|
||||
]);
|
||||
});
|
||||
|
||||
// Mock AssignmentFetcher
|
||||
$this->mock(AssignmentFetcher::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('fetch')
|
||||
->once()
|
||||
->with('tenant-123', 'policy-456')
|
||||
->andReturn([
|
||||
[
|
||||
'id' => 'assignment-1',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-abc',
|
||||
],
|
||||
'intent' => 'apply',
|
||||
],
|
||||
[
|
||||
'id' => 'assignment-2',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-def',
|
||||
],
|
||||
'intent' => 'apply',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
// Mock GroupResolver
|
||||
$this->mock(GroupResolver::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('resolveGroupIds')
|
||||
->once()
|
||||
->with(['group-abc', 'group-def'], 'tenant-123')
|
||||
->andReturn([
|
||||
'group-abc' => [
|
||||
'id' => 'group-abc',
|
||||
'displayName' => 'All Users',
|
||||
'orphaned' => false,
|
||||
],
|
||||
'group-def' => [
|
||||
'id' => 'group-def',
|
||||
'displayName' => 'IT Department',
|
||||
'orphaned' => false,
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
// Mock ScopeTagResolver
|
||||
$this->mock(ScopeTagResolver::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('resolve')
|
||||
->once()
|
||||
->with(['0', '123'], Mockery::type(Tenant::class))
|
||||
->andReturn([
|
||||
['id' => '0', 'displayName' => 'Default'],
|
||||
['id' => '123', 'displayName' => 'HR-Admins'],
|
||||
]);
|
||||
});
|
||||
|
||||
/** @var BackupService $backupService */
|
||||
$backupService = app(BackupService::class);
|
||||
|
||||
$backupSet = $backupService->createBackupSet(
|
||||
tenant: $this->tenant,
|
||||
policyIds: [$this->policy->id],
|
||||
actorEmail: $this->user->email,
|
||||
actorName: $this->user->name,
|
||||
name: 'Test Backup with Assignments',
|
||||
includeAssignments: true
|
||||
);
|
||||
|
||||
expect($backupSet)->toBeInstanceOf(BackupSet::class)
|
||||
->and($backupSet->status)->toBe('completed')
|
||||
->and($backupSet->item_count)->toBe(1);
|
||||
|
||||
$backupItem = $backupSet->items()->first();
|
||||
|
||||
expect($backupItem)->toBeInstanceOf(BackupItem::class)
|
||||
->and($backupItem->assignments)->toBeArray()
|
||||
->and($backupItem->assignments)->toHaveCount(2)
|
||||
->and($backupItem->metadata['assignment_count'])->toBe(2)
|
||||
->and($backupItem->metadata['scope_tag_ids'])->toBe(['0', '123'])
|
||||
->and($backupItem->metadata['scope_tag_names'])->toBe(['Default', 'HR-Admins'])
|
||||
->and($backupItem->metadata['has_orphaned_assignments'])->toBeFalse()
|
||||
->and($backupItem->metadata['assignments_fetch_failed'] ?? false)->toBeFalse();
|
||||
|
||||
// Verify audit log
|
||||
$this->assertDatabaseHas('audit_logs', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'action' => 'backup.created',
|
||||
'resource_type' => 'backup_set',
|
||||
'resource_id' => (string) $backupSet->id,
|
||||
'status' => 'success',
|
||||
]);
|
||||
});
|
||||
|
||||
test('creates backup without assignments when checkbox disabled', function () {
|
||||
// Mock PolicySnapshotService
|
||||
$this->mock(PolicySnapshotService::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('fetch')
|
||||
->once()
|
||||
->andReturn([
|
||||
'payload' => [
|
||||
'id' => 'policy-456',
|
||||
'name' => 'Test Policy',
|
||||
'roleScopeTagIds' => ['0', '123'],
|
||||
'settings' => [],
|
||||
],
|
||||
'metadata' => [],
|
||||
'warnings' => [],
|
||||
]);
|
||||
});
|
||||
|
||||
// AssignmentFetcher should NOT be called
|
||||
$this->mock(AssignmentFetcher::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('fetch')->never();
|
||||
});
|
||||
|
||||
// GroupResolver should NOT be called for assignments
|
||||
$this->mock(GroupResolver::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('resolveGroupIds')->never();
|
||||
});
|
||||
|
||||
// ScopeTagResolver should still be called for scope tags
|
||||
$this->mock(ScopeTagResolver::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('resolve')
|
||||
->once()
|
||||
->with(['0', '123'], Mockery::type(Tenant::class))
|
||||
->andReturn([
|
||||
['id' => '0', 'displayName' => 'Default'],
|
||||
['id' => '123', 'displayName' => 'HR-Admins'],
|
||||
]);
|
||||
});
|
||||
|
||||
/** @var BackupService $backupService */
|
||||
$backupService = app(BackupService::class);
|
||||
|
||||
$backupSet = $backupService->createBackupSet(
|
||||
tenant: $this->tenant,
|
||||
policyIds: [$this->policy->id],
|
||||
actorEmail: $this->user->email,
|
||||
actorName: $this->user->name,
|
||||
name: 'Test Backup without Assignments',
|
||||
includeAssignments: false
|
||||
);
|
||||
|
||||
expect($backupSet)->toBeInstanceOf(BackupSet::class)
|
||||
->and($backupSet->status)->toBe('completed')
|
||||
->and($backupSet->item_count)->toBe(1);
|
||||
|
||||
$backupItem = $backupSet->items()->first();
|
||||
|
||||
expect($backupItem)->toBeInstanceOf(BackupItem::class)
|
||||
->and($backupItem->assignments)->toBeNull()
|
||||
->and($backupItem->metadata['assignment_count'] ?? 0)->toBe(0)
|
||||
->and($backupItem->metadata['scope_tag_ids'])->toBe(['0', '123'])
|
||||
->and($backupItem->metadata['scope_tag_names'])->toBe(['Default', 'HR-Admins']);
|
||||
});
|
||||
|
||||
test('handles fetch failure gracefully', function () {
|
||||
// Mock PolicySnapshotService
|
||||
$this->mock(PolicySnapshotService::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('fetch')
|
||||
->once()
|
||||
->andReturn([
|
||||
'payload' => [
|
||||
'id' => 'policy-456',
|
||||
'name' => 'Test Policy',
|
||||
'roleScopeTagIds' => ['0', '123'],
|
||||
'settings' => [],
|
||||
],
|
||||
'metadata' => [],
|
||||
'warnings' => [],
|
||||
]);
|
||||
});
|
||||
|
||||
// Mock AssignmentFetcher to throw exception
|
||||
$this->mock(AssignmentFetcher::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('fetch')
|
||||
->once()
|
||||
->with('tenant-123', 'policy-456')
|
||||
->andReturn([]); // Returns empty array on failure (fail-soft)
|
||||
});
|
||||
|
||||
// Mock GroupResolver (won't be called if assignments empty)
|
||||
$this->mock(GroupResolver::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('resolveGroupIds')->never();
|
||||
});
|
||||
|
||||
// Mock ScopeTagResolver
|
||||
$this->mock(ScopeTagResolver::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('resolve')
|
||||
->once()
|
||||
->with(['0', '123'], Mockery::type(Tenant::class))
|
||||
->andReturn([
|
||||
['id' => '0', 'displayName' => 'Default'],
|
||||
['id' => '123', 'displayName' => 'HR-Admins'],
|
||||
]);
|
||||
});
|
||||
|
||||
/** @var BackupService $backupService */
|
||||
$backupService = app(BackupService::class);
|
||||
|
||||
$backupSet = $backupService->createBackupSet(
|
||||
tenant: $this->tenant,
|
||||
policyIds: [$this->policy->id],
|
||||
actorEmail: $this->user->email,
|
||||
actorName: $this->user->name,
|
||||
name: 'Test Backup with Fetch Failure',
|
||||
includeAssignments: true
|
||||
);
|
||||
|
||||
// Backup should still complete (fail-soft)
|
||||
expect($backupSet)->toBeInstanceOf(BackupSet::class)
|
||||
->and($backupSet->status)->toBe('completed')
|
||||
->and($backupSet->item_count)->toBe(1);
|
||||
|
||||
$backupItem = $backupSet->items()->first();
|
||||
|
||||
expect($backupItem)->toBeInstanceOf(BackupItem::class)
|
||||
->and($backupItem->assignments)->toBeArray()
|
||||
->and($backupItem->assignments)->toBeEmpty()
|
||||
->and($backupItem->metadata['assignment_count'])->toBe(0);
|
||||
});
|
||||
|
||||
test('detects orphaned groups', function () {
|
||||
// Mock PolicySnapshotService
|
||||
$this->mock(PolicySnapshotService::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('fetch')
|
||||
->once()
|
||||
->andReturn([
|
||||
'payload' => [
|
||||
'id' => 'policy-456',
|
||||
'name' => 'Test Policy',
|
||||
'roleScopeTagIds' => ['0', '123'],
|
||||
'settings' => [],
|
||||
],
|
||||
'metadata' => [],
|
||||
'warnings' => [],
|
||||
]);
|
||||
});
|
||||
|
||||
// Mock AssignmentFetcher
|
||||
$this->mock(AssignmentFetcher::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('fetch')
|
||||
->once()
|
||||
->with('tenant-123', 'policy-456')
|
||||
->andReturn([
|
||||
[
|
||||
'id' => 'assignment-1',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-abc',
|
||||
],
|
||||
'intent' => 'apply',
|
||||
],
|
||||
[
|
||||
'id' => 'assignment-2',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-orphaned',
|
||||
],
|
||||
'intent' => 'apply',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
// Mock GroupResolver with orphaned group
|
||||
$this->mock(GroupResolver::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('resolveGroupIds')
|
||||
->once()
|
||||
->with(['group-abc', 'group-orphaned'], 'tenant-123')
|
||||
->andReturn([
|
||||
'group-abc' => [
|
||||
'id' => 'group-abc',
|
||||
'displayName' => 'All Users',
|
||||
'orphaned' => false,
|
||||
],
|
||||
'group-orphaned' => [
|
||||
'id' => 'group-orphaned',
|
||||
'displayName' => null,
|
||||
'orphaned' => true,
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
// Mock ScopeTagResolver
|
||||
$this->mock(ScopeTagResolver::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('resolve')
|
||||
->once()
|
||||
->with(['0', '123'], Mockery::type(Tenant::class))
|
||||
->andReturn([
|
||||
['id' => '0', 'displayName' => 'Default'],
|
||||
['id' => '123', 'displayName' => 'HR-Admins'],
|
||||
]);
|
||||
});
|
||||
|
||||
/** @var BackupService $backupService */
|
||||
$backupService = app(BackupService::class);
|
||||
|
||||
$backupSet = $backupService->createBackupSet(
|
||||
tenant: $this->tenant,
|
||||
policyIds: [$this->policy->id],
|
||||
actorEmail: $this->user->email,
|
||||
actorName: $this->user->name,
|
||||
name: 'Test Backup with Orphaned Groups',
|
||||
includeAssignments: true
|
||||
);
|
||||
|
||||
$backupItem = $backupSet->items()->first();
|
||||
|
||||
expect($backupItem->metadata['has_orphaned_assignments'])->toBeTrue()
|
||||
->and($backupItem->metadata['assignment_count'])->toBe(2);
|
||||
});
|
||||
|
||||
test('adds policies to existing backup set with assignments', function () {
|
||||
// Create an existing backup set without assignments
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'name' => 'Existing Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
|
||||
// Create a second policy to add
|
||||
$secondPolicy = Policy::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'external_id' => 'policy-789',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'platform' => 'windows10',
|
||||
]);
|
||||
|
||||
// Mock PolicySnapshotService
|
||||
$this->mock(PolicySnapshotService::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('fetch')
|
||||
->once()
|
||||
->andReturn([
|
||||
'payload' => [
|
||||
'id' => 'policy-789',
|
||||
'name' => 'Second Policy',
|
||||
'roleScopeTagIds' => ['0'],
|
||||
'settings' => [],
|
||||
],
|
||||
'metadata' => [],
|
||||
'warnings' => [],
|
||||
]);
|
||||
});
|
||||
|
||||
// Mock AssignmentFetcher
|
||||
$this->mock(AssignmentFetcher::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('fetch')
|
||||
->once()
|
||||
->with('tenant-123', 'policy-789')
|
||||
->andReturn([
|
||||
[
|
||||
'id' => 'assignment-3',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-xyz',
|
||||
],
|
||||
'intent' => 'apply',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
// Mock GroupResolver
|
||||
$this->mock(GroupResolver::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('resolveGroupIds')
|
||||
->once()
|
||||
->with(['group-xyz'], 'tenant-123')
|
||||
->andReturn([
|
||||
'group-xyz' => [
|
||||
'id' => 'group-xyz',
|
||||
'displayName' => 'Test Group',
|
||||
'orphaned' => false,
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
// Mock ScopeTagResolver
|
||||
$this->mock(ScopeTagResolver::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('resolve')
|
||||
->once()
|
||||
->with(['0'], Mockery::type(Tenant::class))
|
||||
->andReturn([
|
||||
['id' => '0', 'displayName' => 'Default'],
|
||||
]);
|
||||
});
|
||||
|
||||
/** @var BackupService $backupService */
|
||||
$backupService = app(BackupService::class);
|
||||
|
||||
$updatedBackupSet = $backupService->addPoliciesToSet(
|
||||
tenant: $this->tenant,
|
||||
backupSet: $backupSet,
|
||||
policyIds: [$secondPolicy->id],
|
||||
actorEmail: $this->user->email,
|
||||
actorName: $this->user->name,
|
||||
includeAssignments: true
|
||||
);
|
||||
|
||||
expect($updatedBackupSet->item_count)->toBe(1);
|
||||
|
||||
$backupItem = $updatedBackupSet->items()->first();
|
||||
|
||||
expect($backupItem)->toBeInstanceOf(BackupItem::class)
|
||||
->and($backupItem->assignments)->toBeArray()
|
||||
->and($backupItem->assignments)->toHaveCount(1)
|
||||
->and($backupItem->metadata['assignment_count'])->toBe(1)
|
||||
->and($backupItem->metadata['scope_tag_ids'])->toBe(['0'])
|
||||
->and($backupItem->metadata['scope_tag_names'])->toBe(['Default'])
|
||||
->and($backupItem->metadata['has_orphaned_assignments'])->toBeFalse();
|
||||
});
|
||||
|
||||
test('adds policies to existing backup set without assignments when flag is false', function () {
|
||||
// Create an existing backup set
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'name' => 'Existing Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
|
||||
// Create a second policy
|
||||
$secondPolicy = Policy::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'external_id' => 'policy-999',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'platform' => 'windows10',
|
||||
]);
|
||||
|
||||
// Mock PolicySnapshotService
|
||||
$this->mock(PolicySnapshotService::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('fetch')
|
||||
->once()
|
||||
->andReturn([
|
||||
'payload' => [
|
||||
'id' => 'policy-999',
|
||||
'name' => 'Third Policy',
|
||||
'roleScopeTagIds' => ['0'],
|
||||
'settings' => [],
|
||||
],
|
||||
'metadata' => [],
|
||||
'warnings' => [],
|
||||
]);
|
||||
});
|
||||
|
||||
// AssignmentFetcher should NOT be called when includeAssignments is false
|
||||
$this->mock(AssignmentFetcher::class, function (MockInterface $mock) {
|
||||
$mock->shouldNotReceive('fetch');
|
||||
});
|
||||
|
||||
// Mock ScopeTagResolver (still called for scope tags in policy payload)
|
||||
$this->mock(ScopeTagResolver::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('resolve')
|
||||
->once()
|
||||
->with(['0'], Mockery::type(Tenant::class))
|
||||
->andReturn([
|
||||
['id' => '0', 'displayName' => 'Default'],
|
||||
]);
|
||||
});
|
||||
|
||||
/** @var BackupService $backupService */
|
||||
$backupService = app(BackupService::class);
|
||||
|
||||
$updatedBackupSet = $backupService->addPoliciesToSet(
|
||||
tenant: $this->tenant,
|
||||
backupSet: $backupSet,
|
||||
policyIds: [$secondPolicy->id],
|
||||
actorEmail: $this->user->email,
|
||||
actorName: $this->user->name,
|
||||
includeAssignments: false
|
||||
);
|
||||
|
||||
expect($updatedBackupSet->item_count)->toBe(1);
|
||||
|
||||
$backupItem = $updatedBackupSet->items()->first();
|
||||
|
||||
expect($backupItem)->toBeInstanceOf(BackupItem::class)
|
||||
->and($backupItem->assignments)->toBeNull()
|
||||
->and($backupItem->metadata['assignment_count'])->toBe(0)
|
||||
->and($backupItem->metadata['scope_tag_ids'])->toBe(['0'])
|
||||
->and($backupItem->metadata['scope_tag_names'])->toBe(['Default']);
|
||||
});
|
||||
74
tests/Feature/PolicyVersionViewAssignmentsTest.php
Normal file
74
tests/Feature/PolicyVersionViewAssignmentsTest.php
Normal file
@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->policy = Policy::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
]);
|
||||
$this->user = User::factory()->create();
|
||||
});
|
||||
|
||||
it('displays policy version page', function () {
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'policy_id' => $this->policy->id,
|
||||
'version_number' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$response = $this->get("/admin/policy-versions/{$version->id}");
|
||||
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
it('displays assignments widget when version has assignments', function () {
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'policy_id' => $this->policy->id,
|
||||
'version_number' => 1,
|
||||
'assignments' => [
|
||||
[
|
||||
'id' => 'assignment-1',
|
||||
'intent' => 'apply',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-123',
|
||||
],
|
||||
],
|
||||
],
|
||||
'scope_tags' => [
|
||||
'ids' => ['0'],
|
||||
'names' => ['Default'],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$response = $this->get("/admin/policy-versions/{$version->id}");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSeeLivewire('policy-version-assignments-widget');
|
||||
$response->assertSee('1 assignment(s)');
|
||||
});
|
||||
|
||||
it('displays empty state when version has no assignments', function () {
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'policy_id' => $this->policy->id,
|
||||
'version_number' => 1,
|
||||
'assignments' => null,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$response = $this->get("/admin/policy-versions/{$version->id}");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('Assignments were not captured for this version');
|
||||
});
|
||||
173
tests/Feature/VersionCaptureWithAssignmentsTest.php
Normal file
173
tests/Feature/VersionCaptureWithAssignmentsTest.php
Normal file
@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\AssignmentFetcher;
|
||||
use App\Services\Graph\GroupResolver;
|
||||
use App\Services\Intune\PolicySnapshotService;
|
||||
use App\Services\Intune\VersionService;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->policy = Policy::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'external_id' => 'test-policy-id',
|
||||
]);
|
||||
});
|
||||
|
||||
it('captures policy version with assignments from graph', function () {
|
||||
// Mock dependencies
|
||||
$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',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-123',
|
||||
],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
$this->mock(GroupResolver::class, function ($mock) {
|
||||
$mock->shouldReceive('resolve')
|
||||
->once()
|
||||
->andReturn([
|
||||
'resolved' => [
|
||||
'group-123' => ['id' => 'group-123', 'displayName' => 'Test Group'],
|
||||
],
|
||||
'orphaned' => [],
|
||||
]);
|
||||
});
|
||||
|
||||
$versionService = app(VersionService::class);
|
||||
$version = $versionService->captureFromGraph(
|
||||
$this->tenant,
|
||||
$this->policy,
|
||||
'test@example.com'
|
||||
);
|
||||
|
||||
expect($version)->not->toBeNull()
|
||||
->and($version->assignments)->not->toBeNull()
|
||||
->and($version->assignments)->toHaveCount(1)
|
||||
->and($version->assignments[0]['target']['groupId'])->toBe('group-123')
|
||||
->and($version->assignments_hash)->not->toBeNull()
|
||||
->and($version->metadata['assignments_count'])->toBe(1)
|
||||
->and($version->metadata['has_orphaned_assignments'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('captures policy version without assignments when none exist', function () {
|
||||
// Mock dependencies
|
||||
$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([]);
|
||||
});
|
||||
|
||||
$versionService = app(VersionService::class);
|
||||
$version = $versionService->captureFromGraph(
|
||||
$this->tenant,
|
||||
$this->policy,
|
||||
'test@example.com'
|
||||
);
|
||||
|
||||
expect($version)->not->toBeNull()
|
||||
->and($version->assignments)->toBeNull()
|
||||
->and($version->assignments_hash)->toBeNull();
|
||||
});
|
||||
|
||||
it('handles assignment fetch failure gracefully', function () {
|
||||
// Mock dependencies
|
||||
$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()
|
||||
->andThrow(new \Exception('Graph API error'));
|
||||
});
|
||||
|
||||
$versionService = app(VersionService::class);
|
||||
$version = $versionService->captureFromGraph(
|
||||
$this->tenant,
|
||||
$this->policy,
|
||||
'test@example.com'
|
||||
);
|
||||
|
||||
expect($version)->not->toBeNull()
|
||||
->and($version->assignments)->toBeNull()
|
||||
->and($version->metadata['assignments_fetch_failed'])->toBeTrue()
|
||||
->and($version->metadata['assignments_fetch_error'])->toBe('Graph API error');
|
||||
});
|
||||
|
||||
it('calculates correct hash for assignments', function () {
|
||||
$assignments = [
|
||||
['id' => '1', 'target' => ['groupId' => 'group-1']],
|
||||
['id' => '2', 'target' => ['groupId' => 'group-2']],
|
||||
];
|
||||
|
||||
$version = $this->policy->versions()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'policy_id' => $this->policy->id,
|
||||
'version_number' => 1,
|
||||
'policy_type' => 'deviceManagementConfigurationPolicy',
|
||||
'snapshot' => ['test' => 'data'],
|
||||
'assignments' => $assignments,
|
||||
'assignments_hash' => hash('sha256', json_encode($assignments)),
|
||||
'captured_at' => now(),
|
||||
]);
|
||||
|
||||
$expectedHash = hash('sha256', json_encode($assignments));
|
||||
|
||||
expect($version->assignments_hash)->toBe($expectedHash);
|
||||
|
||||
// Verify same assignments produce same hash
|
||||
$version2 = $this->policy->versions()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'policy_id' => $this->policy->id,
|
||||
'version_number' => 2,
|
||||
'policy_type' => 'deviceManagementConfigurationPolicy',
|
||||
'snapshot' => ['test' => 'data'],
|
||||
'assignments' => $assignments,
|
||||
'assignments_hash' => hash('sha256', json_encode($assignments)),
|
||||
'captured_at' => now(),
|
||||
]);
|
||||
|
||||
expect($version2->assignments_hash)->toBe($version->assignments_hash);
|
||||
});
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
use App\Services\Graph\AssignmentFetcher;
|
||||
use App\Services\Graph\GraphException;
|
||||
use App\Services\Graph\GraphLogger;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Graph\MicrosoftGraphClient;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
@ -11,8 +11,7 @@
|
||||
|
||||
beforeEach(function () {
|
||||
$this->graphClient = Mockery::mock(MicrosoftGraphClient::class);
|
||||
$this->logger = Mockery::mock(GraphLogger::class);
|
||||
$this->fetcher = new AssignmentFetcher($this->graphClient, $this->logger);
|
||||
$this->fetcher = new AssignmentFetcher($this->graphClient);
|
||||
});
|
||||
|
||||
test('primary endpoint success', function () {
|
||||
@ -23,16 +22,18 @@
|
||||
['id' => 'assign-2', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-2']],
|
||||
];
|
||||
|
||||
$this->graphClient
|
||||
->shouldReceive('get')
|
||||
->once()
|
||||
->with("/deviceManagement/configurationPolicies/{$policyId}/assignments", $tenantId)
|
||||
->andReturn(['value' => $assignments]);
|
||||
$response = new GraphResponse(
|
||||
success: true,
|
||||
data: ['value' => $assignments]
|
||||
);
|
||||
|
||||
$this->logger
|
||||
->shouldReceive('logDebug')
|
||||
$this->graphClient
|
||||
->shouldReceive('request')
|
||||
->once()
|
||||
->with('Fetched assignments via primary endpoint', Mockery::any());
|
||||
->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", [
|
||||
'tenant' => $tenantId,
|
||||
])
|
||||
->andReturn($response);
|
||||
|
||||
$result = $this->fetcher->fetch($tenantId, $policyId);
|
||||
|
||||
@ -47,25 +48,36 @@
|
||||
];
|
||||
|
||||
// Primary returns empty
|
||||
$primaryResponse = new GraphResponse(
|
||||
success: true,
|
||||
data: ['value' => []]
|
||||
);
|
||||
|
||||
$this->graphClient
|
||||
->shouldReceive('get')
|
||||
->shouldReceive('request')
|
||||
->once()
|
||||
->with("/deviceManagement/configurationPolicies/{$policyId}/assignments", $tenantId)
|
||||
->andReturn(['value' => []]);
|
||||
->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", [
|
||||
'tenant' => $tenantId,
|
||||
])
|
||||
->andReturn($primaryResponse);
|
||||
|
||||
// Fallback returns assignments
|
||||
$this->graphClient
|
||||
->shouldReceive('get')
|
||||
->once()
|
||||
->with('/deviceManagement/configurationPolicies', $tenantId, [
|
||||
'$expand' => 'assignments',
|
||||
'$filter' => "id eq '{$policyId}'",
|
||||
])
|
||||
->andReturn(['value' => [['id' => $policyId, 'assignments' => $assignments]]]);
|
||||
$fallbackResponse = new GraphResponse(
|
||||
success: true,
|
||||
data: ['value' => [['id' => $policyId, 'assignments' => $assignments]]]
|
||||
);
|
||||
|
||||
$this->logger
|
||||
->shouldReceive('logDebug')
|
||||
->twice();
|
||||
$this->graphClient
|
||||
->shouldReceive('request')
|
||||
->once()
|
||||
->with('GET', '/deviceManagement/configurationPolicies', [
|
||||
'tenant' => $tenantId,
|
||||
'query' => [
|
||||
'$expand' => 'assignments',
|
||||
'$filter' => "id eq '{$policyId}'",
|
||||
],
|
||||
])
|
||||
->andReturn($fallbackResponse);
|
||||
|
||||
$result = $this->fetcher->fetch($tenantId, $policyId);
|
||||
|
||||
@ -77,19 +89,10 @@
|
||||
$policyId = 'policy-456';
|
||||
|
||||
$this->graphClient
|
||||
->shouldReceive('get')
|
||||
->shouldReceive('request')
|
||||
->once()
|
||||
->andThrow(new GraphException('Graph API error', 500, ['request_id' => 'request-id-123']));
|
||||
|
||||
$this->logger
|
||||
->shouldReceive('logWarning')
|
||||
->once()
|
||||
->with('Failed to fetch assignments', Mockery::on(function ($context) use ($tenantId, $policyId) {
|
||||
return $context['tenant_id'] === $tenantId
|
||||
&& $context['policy_id'] === $policyId
|
||||
&& isset($context['context']['request_id']);
|
||||
}));
|
||||
|
||||
$result = $this->fetcher->fetch($tenantId, $policyId);
|
||||
|
||||
expect($result)->toBe([]);
|
||||
@ -100,22 +103,28 @@
|
||||
$policyId = 'policy-456';
|
||||
|
||||
// Primary returns empty
|
||||
$primaryResponse = new GraphResponse(
|
||||
success: true,
|
||||
data: ['value' => []]
|
||||
);
|
||||
|
||||
$this->graphClient
|
||||
->shouldReceive('get')
|
||||
->shouldReceive('request')
|
||||
->once()
|
||||
->with("/deviceManagement/configurationPolicies/{$policyId}/assignments", $tenantId)
|
||||
->andReturn(['value' => []]);
|
||||
->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", Mockery::any())
|
||||
->andReturn($primaryResponse);
|
||||
|
||||
// Fallback returns empty
|
||||
$this->graphClient
|
||||
->shouldReceive('get')
|
||||
->once()
|
||||
->with('/deviceManagement/configurationPolicies', $tenantId, Mockery::any())
|
||||
->andReturn(['value' => []]);
|
||||
$fallbackResponse = new GraphResponse(
|
||||
success: true,
|
||||
data: ['value' => []]
|
||||
);
|
||||
|
||||
$this->logger
|
||||
->shouldReceive('logDebug')
|
||||
->times(2);
|
||||
$this->graphClient
|
||||
->shouldReceive('request')
|
||||
->once()
|
||||
->with('GET', '/deviceManagement/configurationPolicies', Mockery::any())
|
||||
->andReturn($fallbackResponse);
|
||||
|
||||
$result = $this->fetcher->fetch($tenantId, $policyId);
|
||||
|
||||
@ -127,20 +136,26 @@
|
||||
$policyId = 'policy-456';
|
||||
|
||||
// Primary returns empty
|
||||
$primaryResponse = new GraphResponse(
|
||||
success: true,
|
||||
data: ['value' => []]
|
||||
);
|
||||
|
||||
$this->graphClient
|
||||
->shouldReceive('get')
|
||||
->shouldReceive('request')
|
||||
->once()
|
||||
->andReturn(['value' => []]);
|
||||
->andReturn($primaryResponse);
|
||||
|
||||
// Fallback returns policy without assignments key
|
||||
$this->graphClient
|
||||
->shouldReceive('get')
|
||||
->once()
|
||||
->andReturn(['value' => [['id' => $policyId]]]);
|
||||
$fallbackResponse = new GraphResponse(
|
||||
success: true,
|
||||
data: ['value' => [['id' => $policyId]]]
|
||||
);
|
||||
|
||||
$this->logger
|
||||
->shouldReceive('logDebug')
|
||||
->times(2);
|
||||
$this->graphClient
|
||||
->shouldReceive('request')
|
||||
->once()
|
||||
->andReturn($fallbackResponse);
|
||||
|
||||
$result = $this->fetcher->fetch($tenantId, $policyId);
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Graph\GraphException;
|
||||
use App\Services\Graph\GraphLogger;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Graph\GroupResolver;
|
||||
use App\Services\Graph\MicrosoftGraphClient;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@ -13,14 +13,13 @@
|
||||
beforeEach(function () {
|
||||
Cache::flush();
|
||||
$this->graphClient = Mockery::mock(MicrosoftGraphClient::class);
|
||||
$this->logger = Mockery::mock(GraphLogger::class);
|
||||
$this->resolver = new GroupResolver($this->graphClient, $this->logger);
|
||||
$this->resolver = new GroupResolver($this->graphClient);
|
||||
});
|
||||
|
||||
test('resolves all groups', function () {
|
||||
$tenantId = 'tenant-123';
|
||||
$groupIds = ['group-1', 'group-2', 'group-3'];
|
||||
$graphResponse = [
|
||||
$graphData = [
|
||||
'value' => [
|
||||
['id' => 'group-1', 'displayName' => 'All Users'],
|
||||
['id' => 'group-2', 'displayName' => 'HR Team'],
|
||||
@ -28,18 +27,22 @@
|
||||
],
|
||||
];
|
||||
|
||||
$this->graphClient
|
||||
->shouldReceive('post')
|
||||
->once()
|
||||
->with('/directoryObjects/getByIds', [
|
||||
'ids' => $groupIds,
|
||||
'types' => ['group'],
|
||||
], $tenantId)
|
||||
->andReturn($graphResponse);
|
||||
$response = new GraphResponse(
|
||||
success: true,
|
||||
data: $graphData
|
||||
);
|
||||
|
||||
$this->logger
|
||||
->shouldReceive('logDebug')
|
||||
->once();
|
||||
$this->graphClient
|
||||
->shouldReceive('request')
|
||||
->once()
|
||||
->with('POST', '/directoryObjects/getByIds', [
|
||||
'tenant' => $tenantId,
|
||||
'json' => [
|
||||
'ids' => $groupIds,
|
||||
'types' => ['group'],
|
||||
],
|
||||
])
|
||||
->andReturn($response);
|
||||
|
||||
$result = $this->resolver->resolveGroupIds($groupIds, $tenantId);
|
||||
|
||||
@ -58,26 +61,22 @@
|
||||
test('handles orphaned ids', function () {
|
||||
$tenantId = 'tenant-123';
|
||||
$groupIds = ['group-1', 'group-2', 'group-3'];
|
||||
$graphResponse = [
|
||||
$graphData = [
|
||||
'value' => [
|
||||
['id' => 'group-1', 'displayName' => 'All Users'],
|
||||
// group-2 and group-3 are missing (deleted)
|
||||
],
|
||||
];
|
||||
|
||||
$this->graphClient
|
||||
->shouldReceive('post')
|
||||
->once()
|
||||
->andReturn($graphResponse);
|
||||
$response = new GraphResponse(
|
||||
success: true,
|
||||
data: $graphData
|
||||
);
|
||||
|
||||
$this->logger
|
||||
->shouldReceive('logDebug')
|
||||
$this->graphClient
|
||||
->shouldReceive('request')
|
||||
->once()
|
||||
->with('Resolved group IDs', Mockery::on(function ($context) {
|
||||
return $context['requested'] === 3
|
||||
&& $context['resolved'] === 1
|
||||
&& $context['orphaned'] === 2;
|
||||
}));
|
||||
->andReturn($response);
|
||||
|
||||
$result = $this->resolver->resolveGroupIds($groupIds, $tenantId);
|
||||
|
||||
@ -96,22 +95,23 @@
|
||||
test('caches results', function () {
|
||||
$tenantId = 'tenant-123';
|
||||
$groupIds = ['group-1', 'group-2'];
|
||||
$graphResponse = [
|
||||
$graphData = [
|
||||
'value' => [
|
||||
['id' => 'group-1', 'displayName' => 'All Users'],
|
||||
['id' => 'group-2', 'displayName' => 'HR Team'],
|
||||
],
|
||||
];
|
||||
|
||||
$response = new GraphResponse(
|
||||
success: true,
|
||||
data: $graphData
|
||||
);
|
||||
|
||||
// First call - should hit Graph API
|
||||
$this->graphClient
|
||||
->shouldReceive('post')
|
||||
->shouldReceive('request')
|
||||
->once()
|
||||
->andReturn($graphResponse);
|
||||
|
||||
$this->logger
|
||||
->shouldReceive('logDebug')
|
||||
->once();
|
||||
->andReturn($response);
|
||||
|
||||
$result1 = $this->resolver->resolveGroupIds($groupIds, $tenantId);
|
||||
|
||||
@ -133,18 +133,10 @@
|
||||
$groupIds = ['group-1', 'group-2'];
|
||||
|
||||
$this->graphClient
|
||||
->shouldReceive('post')
|
||||
->shouldReceive('request')
|
||||
->once()
|
||||
->andThrow(new GraphException('Graph API error', 500, ['request_id' => 'request-id-123']));
|
||||
|
||||
$this->logger
|
||||
->shouldReceive('logWarning')
|
||||
->once()
|
||||
->with('Failed to resolve group IDs', Mockery::on(function ($context) use ($groupIds) {
|
||||
return $context['group_ids'] === $groupIds
|
||||
&& isset($context['context']['request_id']);
|
||||
}));
|
||||
|
||||
$result = $this->resolver->resolveGroupIds($groupIds, $tenantId);
|
||||
|
||||
// All groups should be marked as orphaned on failure
|
||||
@ -159,7 +151,7 @@
|
||||
$tenantId = 'tenant-123';
|
||||
$groupIds1 = ['group-1', 'group-2', 'group-3'];
|
||||
$groupIds2 = ['group-3', 'group-1', 'group-2']; // Different order
|
||||
$graphResponse = [
|
||||
$graphData = [
|
||||
'value' => [
|
||||
['id' => 'group-1', 'displayName' => 'All Users'],
|
||||
['id' => 'group-2', 'displayName' => 'HR Team'],
|
||||
@ -167,15 +159,16 @@
|
||||
],
|
||||
];
|
||||
|
||||
$response = new GraphResponse(
|
||||
success: true,
|
||||
data: $graphData
|
||||
);
|
||||
|
||||
// First call with groupIds1
|
||||
$this->graphClient
|
||||
->shouldReceive('post')
|
||||
->shouldReceive('request')
|
||||
->once()
|
||||
->andReturn($graphResponse);
|
||||
|
||||
$this->logger
|
||||
->shouldReceive('logDebug')
|
||||
->once();
|
||||
->andReturn($response);
|
||||
|
||||
$result1 = $this->resolver->resolveGroupIds($groupIds1, $tenantId);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user