test: cover assignment ops observability
This commit is contained in:
parent
1625d5139d
commit
daab886de5
131
tests/Feature/Audit/AssignmentRestoreAuditSummaryTest.php
Normal file
131
tests/Feature/Audit/AssignmentRestoreAuditSummaryTest.php
Normal file
@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('assignment restore emits exactly one summary audit entry per restore execution', function () {
|
||||
app()->instance(GraphClientInterface::class, new class implements GraphClientInterface
|
||||
{
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
});
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-assignment-audit-summary',
|
||||
]);
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'policy-assignment-audit-summary',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_identifier' => (string) $policy->external_id,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'assignments' => [
|
||||
[
|
||||
'id' => 'assignment-1',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-source-1',
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'assignment-2',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-source-2',
|
||||
],
|
||||
],
|
||||
],
|
||||
'payload' => [
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
],
|
||||
]);
|
||||
|
||||
$user = User::factory()->create([
|
||||
'email' => 'assignment.audit.summary@example.com',
|
||||
]);
|
||||
$this->actingAs($user);
|
||||
|
||||
$restoreRun = app(RestoreService::class)->execute(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: [(int) $backupItem->getKey()],
|
||||
dryRun: false,
|
||||
actorEmail: $user->email,
|
||||
actorName: $user->name,
|
||||
groupMapping: [
|
||||
'group-source-1' => 'group-target-1',
|
||||
'group-source-2' => 'group-target-2',
|
||||
],
|
||||
);
|
||||
|
||||
$summaryEntries = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', 'restore.assignments.summary')
|
||||
->where('resource_type', 'restore_run')
|
||||
->where('resource_id', (string) $restoreRun->getKey())
|
||||
->get();
|
||||
|
||||
expect($summaryEntries)->toHaveCount(1);
|
||||
expect($summaryEntries->first()?->metadata['succeeded'] ?? null)->toBe(2);
|
||||
expect($summaryEntries->first()?->metadata['failed'] ?? null)->toBe(0);
|
||||
|
||||
$perAssignmentEntryCount = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->whereIn('action', [
|
||||
'restore.assignment.created',
|
||||
'restore.assignment.failed',
|
||||
'restore.assignment.skipped',
|
||||
])
|
||||
->count();
|
||||
|
||||
expect($perAssignmentEntryCount)->toBe(0);
|
||||
});
|
||||
61
tests/Feature/Graph/AssignmentGraphServiceResolutionTest.php
Normal file
61
tests/Feature/Graph/AssignmentGraphServiceResolutionTest.php
Normal file
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Graph\AssignmentFetcher;
|
||||
use App\Services\Graph\AssignmentFilterResolver;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Graph\GroupResolver;
|
||||
|
||||
it('resolves assignment graph services through the GraphClientInterface binding', function (): void {
|
||||
$fake = new class implements GraphClientInterface
|
||||
{
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
};
|
||||
|
||||
app()->instance(GraphClientInterface::class, $fake);
|
||||
|
||||
$fetcher = app(AssignmentFetcher::class);
|
||||
$groupResolver = app(GroupResolver::class);
|
||||
$filterResolver = app(AssignmentFilterResolver::class);
|
||||
|
||||
$fetcherProperty = new \ReflectionProperty(AssignmentFetcher::class, 'graphClient');
|
||||
$fetcherProperty->setAccessible(true);
|
||||
$groupResolverProperty = new \ReflectionProperty(GroupResolver::class, 'graphClient');
|
||||
$groupResolverProperty->setAccessible(true);
|
||||
$filterResolverProperty = new \ReflectionProperty(AssignmentFilterResolver::class, 'graphClient');
|
||||
$filterResolverProperty->setAccessible(true);
|
||||
|
||||
expect($fetcherProperty->getValue($fetcher))->toBe($fake);
|
||||
expect($groupResolverProperty->getValue($groupResolver))->toBe($fake);
|
||||
expect($filterResolverProperty->getValue($filterResolver))->toBe($fake);
|
||||
});
|
||||
21
tests/Feature/Guards/AdminWorkspaceRoutesGuardTest.php
Normal file
21
tests/Feature/Guards/AdminWorkspaceRoutesGuardTest.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
|
||||
it('returns 404 for platform-guard sessions on admin workspace-scoped routes', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$workspace = $tenant->workspace;
|
||||
|
||||
expect($workspace)->not->toBeNull();
|
||||
|
||||
$platformUser = PlatformUser::factory()->create();
|
||||
|
||||
$workspaceRouteKey = (string) ($workspace->slug ?? $workspace->getKey());
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get("/admin/w/{$workspaceRouteKey}/ping")
|
||||
->assertNotFound();
|
||||
});
|
||||
39
tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php
Normal file
39
tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
|
||||
it('keeps operations list and detail rendering DB-only', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'restore.execute',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
'initiator_name' => 'System',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Bus::fake();
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
assertNoOutboundHttp(function () use ($tenant, $run): void {
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get('/admin/operations')
|
||||
->assertOk()
|
||||
->assertSee('All');
|
||||
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertOk()
|
||||
->assertSee('Operation run');
|
||||
});
|
||||
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
@ -36,6 +36,77 @@
|
||||
expect(OperationRun::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('dedupes assignment run identities by type and scope', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$service = new OperationRunService;
|
||||
|
||||
$fetchRunA = $service->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'assignments.fetch',
|
||||
identityInputs: [
|
||||
'backup_item_id' => 101,
|
||||
],
|
||||
context: [
|
||||
'backup_item_id' => 101,
|
||||
'phase' => 'capture',
|
||||
],
|
||||
);
|
||||
|
||||
$fetchRunB = $service->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'assignments.fetch',
|
||||
identityInputs: [
|
||||
'backup_item_id' => 101,
|
||||
],
|
||||
context: [
|
||||
'backup_item_id' => 101,
|
||||
'phase' => 'capture-again',
|
||||
],
|
||||
);
|
||||
|
||||
$fetchRunDifferentScope = $service->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'assignments.fetch',
|
||||
identityInputs: [
|
||||
'backup_item_id' => 102,
|
||||
],
|
||||
context: [
|
||||
'backup_item_id' => 102,
|
||||
'phase' => 'capture',
|
||||
],
|
||||
);
|
||||
|
||||
$restoreRunA = $service->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'assignments.restore',
|
||||
identityInputs: [
|
||||
'restore_run_id' => 501,
|
||||
],
|
||||
context: [
|
||||
'restore_run_id' => 501,
|
||||
'phase' => 'execute',
|
||||
],
|
||||
);
|
||||
|
||||
$restoreRunB = $service->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'assignments.restore',
|
||||
identityInputs: [
|
||||
'restore_run_id' => 501,
|
||||
],
|
||||
context: [
|
||||
'restore_run_id' => 501,
|
||||
'phase' => 'execute-again',
|
||||
],
|
||||
);
|
||||
|
||||
expect($fetchRunA->getKey())->toBe($fetchRunB->getKey());
|
||||
expect($restoreRunA->getKey())->toBe($restoreRunB->getKey());
|
||||
expect($fetchRunA->getKey())->not->toBe($fetchRunDifferentScope->getKey());
|
||||
expect($fetchRunA->getKey())->not->toBe($restoreRunA->getKey());
|
||||
});
|
||||
|
||||
it('does not replace the initiator when deduping', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$userA = User::factory()->create();
|
||||
|
||||
@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Graph\ScopeTagResolver;
|
||||
use App\Services\Intune\BackupService;
|
||||
use App\Services\Intune\PolicySnapshotService;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery\MockInterface;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('assignment fetch operation run fails safely when graph assignment fetch fails', function () {
|
||||
app()->instance(GraphClientInterface::class, new class implements GraphClientInterface
|
||||
{
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(
|
||||
success: false,
|
||||
data: [],
|
||||
status: 400,
|
||||
errors: [
|
||||
['code' => 'BadRequest', 'message' => 'Bad request'],
|
||||
],
|
||||
warnings: [],
|
||||
meta: [
|
||||
'error_code' => 'BadRequest',
|
||||
'error_message' => 'Assignment list request failed',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
});
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-assignment-fetch-failure',
|
||||
'status' => 'active',
|
||||
]);
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'policy-assignment-fetch-failure',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$this->mock(PolicySnapshotService::class, function (MockInterface $mock) use ($policy): void {
|
||||
$mock->shouldReceive('fetch')
|
||||
->once()
|
||||
->andReturn([
|
||||
'payload' => [
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'id' => (string) $policy->external_id,
|
||||
'name' => 'Policy snapshot',
|
||||
'roleScopeTagIds' => ['0'],
|
||||
],
|
||||
'metadata' => [],
|
||||
'warnings' => [],
|
||||
]);
|
||||
});
|
||||
|
||||
$this->mock(ScopeTagResolver::class, function (MockInterface $mock) use ($tenant): void {
|
||||
$mock->shouldReceive('resolve')
|
||||
->once()
|
||||
->with(['0'], $tenant)
|
||||
->andReturn([
|
||||
['id' => '0', 'displayName' => 'Default'],
|
||||
]);
|
||||
});
|
||||
|
||||
$backupSet = app(BackupService::class)->createBackupSet(
|
||||
tenant: $tenant,
|
||||
policyIds: [(int) $policy->getKey()],
|
||||
actorEmail: 'assignment.fetch.failure@example.com',
|
||||
actorName: 'Assignment Fetch Failure',
|
||||
includeAssignments: true,
|
||||
includeScopeTags: true,
|
||||
);
|
||||
|
||||
$backupItem = $backupSet->items()->first();
|
||||
expect($backupItem)->not->toBeNull();
|
||||
expect($backupItem?->metadata['assignments_fetch_failed'] ?? false)->toBeTrue();
|
||||
|
||||
$assignmentFetchRun = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', 'assignments.fetch')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($assignmentFetchRun)->not->toBeNull();
|
||||
expect($assignmentFetchRun?->status)->toBe('completed');
|
||||
expect($assignmentFetchRun?->outcome)->toBe('failed');
|
||||
expect($assignmentFetchRun?->summary_counts ?? [])->toMatchArray([
|
||||
'total' => 1,
|
||||
'processed' => 0,
|
||||
'failed' => 1,
|
||||
]);
|
||||
expect($assignmentFetchRun?->failure_summary[0]['code'] ?? null)->toBe('assignments.fetch_failed');
|
||||
expect(ProviderReasonCodes::isKnown((string) ($assignmentFetchRun?->failure_summary[0]['reason_code'] ?? '')))->toBeTrue();
|
||||
});
|
||||
114
tests/Feature/Operations/AssignmentFetchOperationRunTest.php
Normal file
114
tests/Feature/Operations/AssignmentFetchOperationRunTest.php
Normal file
@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\AssignmentFetcher;
|
||||
use App\Services\Graph\AssignmentFilterResolver;
|
||||
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);
|
||||
|
||||
test('backup capture with assignments writes an assignment fetch operation run keyed by backup item', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-assignment-fetch-run',
|
||||
'status' => 'active',
|
||||
]);
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'policy-assignment-fetch-run',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$this->mock(PolicySnapshotService::class, function (MockInterface $mock) use ($policy): void {
|
||||
$mock->shouldReceive('fetch')
|
||||
->once()
|
||||
->andReturn([
|
||||
'payload' => [
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'id' => (string) $policy->external_id,
|
||||
'name' => 'Policy snapshot',
|
||||
'roleScopeTagIds' => ['0'],
|
||||
],
|
||||
'metadata' => [],
|
||||
'warnings' => [],
|
||||
]);
|
||||
});
|
||||
|
||||
$this->mock(AssignmentFetcher::class, function (MockInterface $mock): void {
|
||||
$mock->shouldReceive('fetch')
|
||||
->once()
|
||||
->andReturn([
|
||||
[
|
||||
'id' => 'assignment-1',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-1',
|
||||
],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
$this->mock(GroupResolver::class, function (MockInterface $mock): void {
|
||||
$mock->shouldReceive('resolveGroupIds')
|
||||
->once()
|
||||
->andReturn([
|
||||
'group-1' => [
|
||||
'id' => 'group-1',
|
||||
'displayName' => 'Group One',
|
||||
'orphaned' => false,
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
$this->mock(AssignmentFilterResolver::class, function (MockInterface $mock): void {
|
||||
$mock->shouldReceive('resolve')
|
||||
->once()
|
||||
->andReturn([]);
|
||||
});
|
||||
|
||||
$this->mock(ScopeTagResolver::class, function (MockInterface $mock) use ($tenant): void {
|
||||
$mock->shouldReceive('resolve')
|
||||
->once()
|
||||
->with(['0'], $tenant)
|
||||
->andReturn([
|
||||
['id' => '0', 'displayName' => 'Default'],
|
||||
]);
|
||||
});
|
||||
|
||||
$backupSet = app(BackupService::class)->createBackupSet(
|
||||
tenant: $tenant,
|
||||
policyIds: [(int) $policy->getKey()],
|
||||
actorEmail: 'assignment.fetch.run@example.com',
|
||||
actorName: 'Assignment Fetch',
|
||||
includeAssignments: true,
|
||||
includeScopeTags: true,
|
||||
);
|
||||
|
||||
$backupItem = $backupSet->items()->first();
|
||||
expect($backupItem)->not->toBeNull();
|
||||
|
||||
$assignmentFetchRun = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', 'assignments.fetch')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($assignmentFetchRun)->not->toBeNull();
|
||||
expect($assignmentFetchRun?->status)->toBe('completed');
|
||||
expect($assignmentFetchRun?->outcome)->toBe('succeeded');
|
||||
expect($assignmentFetchRun?->context['backup_item_id'] ?? null)->toBe((int) $backupItem?->getKey());
|
||||
expect($assignmentFetchRun?->summary_counts ?? [])->toMatchArray([
|
||||
'total' => 1,
|
||||
'processed' => 1,
|
||||
'failed' => 0,
|
||||
]);
|
||||
});
|
||||
@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('assignment restore writes stable failure code and normalized reason code', function () {
|
||||
app()->instance(GraphClientInterface::class, new class implements GraphClientInterface
|
||||
{
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(
|
||||
success: false,
|
||||
data: [],
|
||||
status: 400,
|
||||
errors: [
|
||||
['code' => 'BadRequest', 'message' => 'Bad request'],
|
||||
],
|
||||
warnings: [],
|
||||
meta: [
|
||||
'error_code' => 'BadRequest',
|
||||
'error_message' => 'Bad request while restoring assignments',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
});
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-assignment-restore-failure',
|
||||
]);
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'policy-assignment-restore-failure',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_identifier' => (string) $policy->external_id,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'assignments' => [
|
||||
[
|
||||
'id' => 'assignment-1',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-source-1',
|
||||
],
|
||||
],
|
||||
],
|
||||
'payload' => [
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
],
|
||||
]);
|
||||
|
||||
$user = User::factory()->create([
|
||||
'email' => 'assignment.restore.failure@example.com',
|
||||
]);
|
||||
$this->actingAs($user);
|
||||
|
||||
app(RestoreService::class)->execute(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: [(int) $backupItem->getKey()],
|
||||
dryRun: false,
|
||||
actorEmail: $user->email,
|
||||
actorName: $user->name,
|
||||
groupMapping: [
|
||||
'group-source-1' => 'group-target-1',
|
||||
],
|
||||
);
|
||||
|
||||
$assignmentRun = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', 'assignments.restore')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($assignmentRun)->not->toBeNull();
|
||||
expect($assignmentRun?->status)->toBe('completed');
|
||||
expect($assignmentRun?->outcome)->toBe('failed');
|
||||
expect($assignmentRun?->summary_counts ?? [])->toMatchArray([
|
||||
'total' => 1,
|
||||
'processed' => 0,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$failure = $assignmentRun?->failure_summary[0] ?? [];
|
||||
|
||||
expect($failure['code'] ?? null)->toBe('assignments.restore_failed');
|
||||
expect($failure['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionInvalid);
|
||||
expect((string) ($failure['message'] ?? ''))->not->toBe('');
|
||||
});
|
||||
117
tests/Feature/Operations/AssignmentRestoreOperationRunTest.php
Normal file
117
tests/Feature/Operations/AssignmentRestoreOperationRunTest.php
Normal file
@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('assignment restore writes an operation run with lifecycle and counters', function () {
|
||||
app()->instance(GraphClientInterface::class, new class implements GraphClientInterface
|
||||
{
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
});
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-assignment-restore-success',
|
||||
]);
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'policy-assignment-restore-success',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_identifier' => (string) $policy->external_id,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'assignments' => [
|
||||
[
|
||||
'id' => 'assignment-1',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-source-1',
|
||||
],
|
||||
],
|
||||
],
|
||||
'payload' => [
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
],
|
||||
]);
|
||||
|
||||
$user = User::factory()->create([
|
||||
'email' => 'assignment.restore.success@example.com',
|
||||
]);
|
||||
$this->actingAs($user);
|
||||
|
||||
$restoreRun = app(RestoreService::class)->execute(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: [(int) $backupItem->getKey()],
|
||||
dryRun: false,
|
||||
actorEmail: $user->email,
|
||||
actorName: $user->name,
|
||||
groupMapping: [
|
||||
'group-source-1' => 'group-target-1',
|
||||
],
|
||||
);
|
||||
|
||||
$assignmentRun = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', 'assignments.restore')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($assignmentRun)->not->toBeNull();
|
||||
expect($assignmentRun?->status)->toBe('completed');
|
||||
expect($assignmentRun?->outcome)->toBe('succeeded');
|
||||
expect($assignmentRun?->context['restore_run_id'] ?? null)->toBe((int) $restoreRun->getKey());
|
||||
expect($assignmentRun?->summary_counts ?? [])->toMatchArray([
|
||||
'total' => 1,
|
||||
'processed' => 1,
|
||||
'failed' => 0,
|
||||
]);
|
||||
});
|
||||
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('does not authorize provider connection create CTA for non-members', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$user->tenants()->detach((int) $tenant->getKey());
|
||||
app(\App\Services\Auth\CapabilityResolver::class)->clearCache();
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListProviderConnections::class)
|
||||
->assertActionExists('create', function (Action $action): bool {
|
||||
return $action->isAuthorized() === false;
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\BackupSetResource\Pages\EditBackupSet;
|
||||
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('returns 404 for non-members before capability checks on backup item actions', function (): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($owner);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
$backupItem = BackupItem::factory()->for($backupSet)->for($tenant)->create();
|
||||
|
||||
$outsider = User::factory()->create();
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $outsider->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$this->actingAs($outsider);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->get(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('keeps actions visible but disabled for members missing capability', function (): void {
|
||||
[$readonlyUser, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$this->actingAs($readonlyUser);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
$backupItem = BackupItem::factory()->for($backupSet)->for($tenant)->create();
|
||||
|
||||
Livewire::test(BackupItemsRelationManager::class, [
|
||||
'ownerRecord' => $backupSet,
|
||||
'pageClass' => EditBackupSet::class,
|
||||
])
|
||||
->assertTableActionVisible('remove', $backupItem)
|
||||
->assertTableActionDisabled('remove', $backupItem);
|
||||
});
|
||||
@ -1,6 +1,5 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\AssignmentRestoreService;
|
||||
use App\Services\Graph\AssignmentFilterResolver;
|
||||
@ -8,7 +7,6 @@
|
||||
use App\Services\Graph\GraphContractRegistry;
|
||||
use App\Services\Graph\GraphLogger;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
@ -31,7 +29,6 @@
|
||||
]);
|
||||
|
||||
$this->graphClient = Mockery::mock(GraphClientInterface::class);
|
||||
$this->auditLogger = Mockery::mock(AuditLogger::class);
|
||||
$this->filterResolver = Mockery::mock(AssignmentFilterResolver::class);
|
||||
$this->filterResolver->shouldReceive('resolve')->andReturn([])->byDefault();
|
||||
|
||||
@ -39,7 +36,6 @@
|
||||
$this->graphClient,
|
||||
app(GraphContractRegistry::class),
|
||||
app(GraphLogger::class),
|
||||
$this->auditLogger,
|
||||
$this->filterResolver,
|
||||
app(MicrosoftGraphOptionsResolver::class),
|
||||
);
|
||||
@ -80,11 +76,6 @@
|
||||
))
|
||||
->andReturn(new GraphResponse(success: true, data: []));
|
||||
|
||||
$this->auditLogger
|
||||
->shouldReceive('log')
|
||||
->once()
|
||||
->andReturn(new AuditLog);
|
||||
|
||||
$result = $this->service->restore(
|
||||
$tenant,
|
||||
'deviceManagementScript',
|
||||
@ -125,11 +116,6 @@
|
||||
))
|
||||
->andReturn(new GraphResponse(success: true, data: []));
|
||||
|
||||
$this->auditLogger
|
||||
->shouldReceive('log')
|
||||
->once()
|
||||
->andReturn(new AuditLog);
|
||||
|
||||
$result = $this->service->restore(
|
||||
$tenant,
|
||||
'appProtectionPolicy',
|
||||
@ -187,11 +173,6 @@
|
||||
))
|
||||
->andReturn(new GraphResponse(success: true, data: []));
|
||||
|
||||
$this->auditLogger
|
||||
->shouldReceive('log')
|
||||
->once()
|
||||
->andReturn(new AuditLog);
|
||||
|
||||
$result = $this->service->restore(
|
||||
$tenant,
|
||||
'settingsCatalogPolicy',
|
||||
@ -245,11 +226,6 @@
|
||||
))
|
||||
->andReturn(new GraphResponse(success: true, data: []));
|
||||
|
||||
$this->auditLogger
|
||||
->shouldReceive('log')
|
||||
->once()
|
||||
->andReturn(new AuditLog);
|
||||
|
||||
$result = $this->service->restore(
|
||||
$tenant,
|
||||
'settingsCatalogPolicy',
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiEnforcement;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@ -18,9 +19,10 @@
|
||||
]);
|
||||
}
|
||||
|
||||
$enforcement = UiEnforcement::for(Capabilities::TENANT_SYNC)
|
||||
->tenantFromRecord()
|
||||
->preflightByCapability();
|
||||
$action = Action::make('test')->action(fn () => null);
|
||||
|
||||
$enforcement = UiEnforcement::forAction($action)
|
||||
->requireCapability(Capabilities::TENANT_SYNC);
|
||||
|
||||
$membershipQueries = 0;
|
||||
|
||||
@ -33,4 +35,3 @@
|
||||
expect($enforcement->bulkSelectionIsAuthorized($user, $tenants))->toBeTrue();
|
||||
expect($membershipQueries)->toBe(1);
|
||||
});
|
||||
|
||||
|
||||
@ -2,31 +2,22 @@
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiEnforcement;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('forbids preserveVisibility on record-scoped tenant resolution', function () {
|
||||
expect(fn () => UiEnforcement::for(Capabilities::TENANT_VIEW)->tenantFromRecord()->preserveVisibility())
|
||||
->toThrow(LogicException::class);
|
||||
|
||||
expect(fn () => UiEnforcement::for(Capabilities::TENANT_VIEW)->preserveVisibility()->tenantFromRecord())
|
||||
->toThrow(LogicException::class);
|
||||
});
|
||||
|
||||
it('hides actions for non-members on record-scoped surfaces', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user] = createUserWithTenant();
|
||||
|
||||
$action = Action::make('test');
|
||||
$action = Action::make('test')->action(fn () => null);
|
||||
|
||||
UiEnforcement::for(Capabilities::TENANT_VIEW)
|
||||
->tenantFromRecord()
|
||||
->apply($action);
|
||||
UiEnforcement::forAction($action)
|
||||
->requireCapability(Capabilities::TENANT_VIEW)
|
||||
->apply();
|
||||
|
||||
$this->actingAs($user);
|
||||
$action->record($tenant);
|
||||
@ -38,11 +29,11 @@
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user] = createUserWithTenant($tenant, role: 'readonly');
|
||||
|
||||
$action = Action::make('test');
|
||||
$action = Action::make('test')->action(fn () => null);
|
||||
|
||||
UiEnforcement::for(Capabilities::TENANT_SYNC)
|
||||
->tenantFromRecord()
|
||||
->apply($action);
|
||||
UiEnforcement::forAction($action)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->apply();
|
||||
|
||||
$this->actingAs($user);
|
||||
$action->record($tenant);
|
||||
@ -56,11 +47,11 @@
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
$action = Action::make('test');
|
||||
$action = Action::make('test')->action(fn () => null);
|
||||
|
||||
UiEnforcement::for(Capabilities::TENANT_SYNC)
|
||||
->tenantFromRecord()
|
||||
->apply($action);
|
||||
UiEnforcement::forAction($action)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->apply();
|
||||
|
||||
$this->actingAs($user);
|
||||
$action->record($tenant);
|
||||
@ -70,36 +61,21 @@
|
||||
expect($action->getTooltip())->toBeNull();
|
||||
});
|
||||
|
||||
it('supports mixed visibility composition via andVisibleWhen', function () {
|
||||
it('preserveVisibility combines existing visibility with membership checks', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
$action = Action::make('test')
|
||||
->action(fn () => null)
|
||||
->visible(fn (): bool => false);
|
||||
|
||||
$action = Action::make('test');
|
||||
|
||||
UiEnforcement::for(Capabilities::TENANT_VIEW)
|
||||
->andVisibleWhen(fn (): bool => false)
|
||||
->apply($action);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
expect($action->isHidden())->toBeTrue();
|
||||
});
|
||||
|
||||
it('supports mixed visibility composition via andHiddenWhen', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$action = Action::make('test');
|
||||
|
||||
UiEnforcement::for(Capabilities::TENANT_VIEW)
|
||||
->andHiddenWhen(fn (): bool => true)
|
||||
->apply($action);
|
||||
UiEnforcement::forAction($action)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_VIEW)
|
||||
->apply();
|
||||
|
||||
$this->actingAs($user);
|
||||
$action->record($tenant);
|
||||
|
||||
expect($action->isHidden())->toBeTrue();
|
||||
});
|
||||
@ -113,9 +89,10 @@
|
||||
$tenantB->getKey() => ['role' => 'readonly'],
|
||||
]);
|
||||
|
||||
$enforcement = UiEnforcement::for(Capabilities::TENANT_SYNC)
|
||||
->tenantFromRecord()
|
||||
->preflightByCapability();
|
||||
$action = Action::make('test')->action(fn () => null);
|
||||
|
||||
$enforcement = UiEnforcement::forAction($action)
|
||||
->requireCapability(Capabilities::TENANT_SYNC);
|
||||
|
||||
expect($enforcement->bulkSelectionIsAuthorized($user, collect([$tenantA, $tenantB])))->toBeFalse();
|
||||
|
||||
@ -125,4 +102,3 @@
|
||||
|
||||
expect($enforcement->bulkSelectionIsAuthorized($user, collect([$tenantA, $tenantB])))->toBeTrue();
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user