TenantAtlas/tests/Feature/BackupWithAssignmentsConsistencyTest.php

265 lines
9.5 KiB
PHP

<?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\GroupResolver;
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(['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);
});