test: cover assignment ops observability

This commit is contained in:
Ahmed Darrazi 2026-02-15 13:39:56 +01:00
parent 1625d5139d
commit daab886de5
14 changed files with 933 additions and 79 deletions

View 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);
});

View 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);
});

View 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();
});

View 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();
});

View File

@ -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();

View File

@ -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();
});

View 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,
]);
});

View File

@ -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('');
});

View 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,
]);
});

View File

@ -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;
});
});

View File

@ -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);
});

View File

@ -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',

View File

@ -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);
});

View File

@ -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();
});