- BackupWithAssignmentsTest: Added missing PolicySnapshotService mock for orphaned groups test
- BackupCreationTest: Added PolicySnapshotService mock with twice() expectation, added last_synced_at to test policies, fixed payload assertion (id instead of policyId)
- PolicyListingTest: Added last_synced_at to test policies
Root cause: Policies without last_synced_at within 7 days are filtered out by BackupItemsRelationManager (Feature 005 workaround for deleted policies)
All tests now passing: 155 passed ✅, 5 skipped
364 lines
12 KiB
PHP
364 lines
12 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);
|
|
});
|