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('resolveGroupIds') ->andReturn([ 'group-123' => [ 'id' => 'group-123', 'displayName' => 'Test Group', 'orphaned' => false, ], ]); }); }); 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); });