TenantAtlas/tests/Feature/BackupWithAssignmentsTest.php
Ahmed Darrazi f314703ac6 fix: capture assignments when adding policies to backup sets
- Add includeAssignments parameter to addPoliciesToSet method
- Add 'Include Assignments' checkbox to UI (default: true)
- Fix AssignmentFetcher to use request() instead of non-existent get()
- Fix GroupResolver to use request() instead of non-existent post()
- Replace GraphLogger calls with Laravel Log facade
- Add tests for addPoliciesToSet with/without assignments
2025-12-22 17:17:52 +01:00

534 lines
18 KiB
PHP

<?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']);
});