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);
|
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 () {
|
it('does not replace the initiator when deduping', function () {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
$userA = User::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
|
<?php
|
||||||
|
|
||||||
use App\Models\AuditLog;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\AssignmentRestoreService;
|
use App\Services\AssignmentRestoreService;
|
||||||
use App\Services\Graph\AssignmentFilterResolver;
|
use App\Services\Graph\AssignmentFilterResolver;
|
||||||
@ -8,7 +7,6 @@
|
|||||||
use App\Services\Graph\GraphContractRegistry;
|
use App\Services\Graph\GraphContractRegistry;
|
||||||
use App\Services\Graph\GraphLogger;
|
use App\Services\Graph\GraphLogger;
|
||||||
use App\Services\Graph\GraphResponse;
|
use App\Services\Graph\GraphResponse;
|
||||||
use App\Services\Intune\AuditLogger;
|
|
||||||
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
@ -31,7 +29,6 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->graphClient = Mockery::mock(GraphClientInterface::class);
|
$this->graphClient = Mockery::mock(GraphClientInterface::class);
|
||||||
$this->auditLogger = Mockery::mock(AuditLogger::class);
|
|
||||||
$this->filterResolver = Mockery::mock(AssignmentFilterResolver::class);
|
$this->filterResolver = Mockery::mock(AssignmentFilterResolver::class);
|
||||||
$this->filterResolver->shouldReceive('resolve')->andReturn([])->byDefault();
|
$this->filterResolver->shouldReceive('resolve')->andReturn([])->byDefault();
|
||||||
|
|
||||||
@ -39,7 +36,6 @@
|
|||||||
$this->graphClient,
|
$this->graphClient,
|
||||||
app(GraphContractRegistry::class),
|
app(GraphContractRegistry::class),
|
||||||
app(GraphLogger::class),
|
app(GraphLogger::class),
|
||||||
$this->auditLogger,
|
|
||||||
$this->filterResolver,
|
$this->filterResolver,
|
||||||
app(MicrosoftGraphOptionsResolver::class),
|
app(MicrosoftGraphOptionsResolver::class),
|
||||||
);
|
);
|
||||||
@ -80,11 +76,6 @@
|
|||||||
))
|
))
|
||||||
->andReturn(new GraphResponse(success: true, data: []));
|
->andReturn(new GraphResponse(success: true, data: []));
|
||||||
|
|
||||||
$this->auditLogger
|
|
||||||
->shouldReceive('log')
|
|
||||||
->once()
|
|
||||||
->andReturn(new AuditLog);
|
|
||||||
|
|
||||||
$result = $this->service->restore(
|
$result = $this->service->restore(
|
||||||
$tenant,
|
$tenant,
|
||||||
'deviceManagementScript',
|
'deviceManagementScript',
|
||||||
@ -125,11 +116,6 @@
|
|||||||
))
|
))
|
||||||
->andReturn(new GraphResponse(success: true, data: []));
|
->andReturn(new GraphResponse(success: true, data: []));
|
||||||
|
|
||||||
$this->auditLogger
|
|
||||||
->shouldReceive('log')
|
|
||||||
->once()
|
|
||||||
->andReturn(new AuditLog);
|
|
||||||
|
|
||||||
$result = $this->service->restore(
|
$result = $this->service->restore(
|
||||||
$tenant,
|
$tenant,
|
||||||
'appProtectionPolicy',
|
'appProtectionPolicy',
|
||||||
@ -187,11 +173,6 @@
|
|||||||
))
|
))
|
||||||
->andReturn(new GraphResponse(success: true, data: []));
|
->andReturn(new GraphResponse(success: true, data: []));
|
||||||
|
|
||||||
$this->auditLogger
|
|
||||||
->shouldReceive('log')
|
|
||||||
->once()
|
|
||||||
->andReturn(new AuditLog);
|
|
||||||
|
|
||||||
$result = $this->service->restore(
|
$result = $this->service->restore(
|
||||||
$tenant,
|
$tenant,
|
||||||
'settingsCatalogPolicy',
|
'settingsCatalogPolicy',
|
||||||
@ -245,11 +226,6 @@
|
|||||||
))
|
))
|
||||||
->andReturn(new GraphResponse(success: true, data: []));
|
->andReturn(new GraphResponse(success: true, data: []));
|
||||||
|
|
||||||
$this->auditLogger
|
|
||||||
->shouldReceive('log')
|
|
||||||
->once()
|
|
||||||
->andReturn(new AuditLog);
|
|
||||||
|
|
||||||
$result = $this->service->restore(
|
$result = $this->service->restore(
|
||||||
$tenant,
|
$tenant,
|
||||||
'settingsCatalogPolicy',
|
'settingsCatalogPolicy',
|
||||||
|
|||||||
@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Auth\Capabilities;
|
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\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
@ -18,9 +19,10 @@
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$enforcement = UiEnforcement::for(Capabilities::TENANT_SYNC)
|
$action = Action::make('test')->action(fn () => null);
|
||||||
->tenantFromRecord()
|
|
||||||
->preflightByCapability();
|
$enforcement = UiEnforcement::forAction($action)
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC);
|
||||||
|
|
||||||
$membershipQueries = 0;
|
$membershipQueries = 0;
|
||||||
|
|
||||||
@ -33,4 +35,3 @@
|
|||||||
expect($enforcement->bulkSelectionIsAuthorized($user, $tenants))->toBeTrue();
|
expect($enforcement->bulkSelectionIsAuthorized($user, $tenants))->toBeTrue();
|
||||||
expect($membershipQueries)->toBe(1);
|
expect($membershipQueries)->toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -2,31 +2,22 @@
|
|||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiEnforcement;
|
|
||||||
use App\Support\Auth\UiTooltips;
|
use App\Support\Auth\UiTooltips;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
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 () {
|
it('hides actions for non-members on record-scoped surfaces', function () {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user] = createUserWithTenant();
|
[$user] = createUserWithTenant();
|
||||||
|
|
||||||
$action = Action::make('test');
|
$action = Action::make('test')->action(fn () => null);
|
||||||
|
|
||||||
UiEnforcement::for(Capabilities::TENANT_VIEW)
|
UiEnforcement::forAction($action)
|
||||||
->tenantFromRecord()
|
->requireCapability(Capabilities::TENANT_VIEW)
|
||||||
->apply($action);
|
->apply();
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
$action->record($tenant);
|
$action->record($tenant);
|
||||||
@ -38,11 +29,11 @@
|
|||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user] = createUserWithTenant($tenant, role: 'readonly');
|
[$user] = createUserWithTenant($tenant, role: 'readonly');
|
||||||
|
|
||||||
$action = Action::make('test');
|
$action = Action::make('test')->action(fn () => null);
|
||||||
|
|
||||||
UiEnforcement::for(Capabilities::TENANT_SYNC)
|
UiEnforcement::forAction($action)
|
||||||
->tenantFromRecord()
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
->apply($action);
|
->apply();
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
$action->record($tenant);
|
$action->record($tenant);
|
||||||
@ -56,11 +47,11 @@
|
|||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user] = createUserWithTenant($tenant, role: 'owner');
|
[$user] = createUserWithTenant($tenant, role: 'owner');
|
||||||
|
|
||||||
$action = Action::make('test');
|
$action = Action::make('test')->action(fn () => null);
|
||||||
|
|
||||||
UiEnforcement::for(Capabilities::TENANT_SYNC)
|
UiEnforcement::forAction($action)
|
||||||
->tenantFromRecord()
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
->apply($action);
|
->apply();
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
$action->record($tenant);
|
$action->record($tenant);
|
||||||
@ -70,36 +61,21 @@
|
|||||||
expect($action->getTooltip())->toBeNull();
|
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();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user] = createUserWithTenant($tenant, role: 'owner');
|
[$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::forAction($action)
|
||||||
|
->preserveVisibility()
|
||||||
UiEnforcement::for(Capabilities::TENANT_VIEW)
|
->requireCapability(Capabilities::TENANT_VIEW)
|
||||||
->andVisibleWhen(fn (): bool => false)
|
->apply();
|
||||||
->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);
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
$action->record($tenant);
|
||||||
|
|
||||||
expect($action->isHidden())->toBeTrue();
|
expect($action->isHidden())->toBeTrue();
|
||||||
});
|
});
|
||||||
@ -113,9 +89,10 @@
|
|||||||
$tenantB->getKey() => ['role' => 'readonly'],
|
$tenantB->getKey() => ['role' => 'readonly'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$enforcement = UiEnforcement::for(Capabilities::TENANT_SYNC)
|
$action = Action::make('test')->action(fn () => null);
|
||||||
->tenantFromRecord()
|
|
||||||
->preflightByCapability();
|
$enforcement = UiEnforcement::forAction($action)
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC);
|
||||||
|
|
||||||
expect($enforcement->bulkSelectionIsAuthorized($user, collect([$tenantA, $tenantB])))->toBeFalse();
|
expect($enforcement->bulkSelectionIsAuthorized($user, collect([$tenantA, $tenantB])))->toBeFalse();
|
||||||
|
|
||||||
@ -125,4 +102,3 @@
|
|||||||
|
|
||||||
expect($enforcement->bulkSelectionIsAuthorized($user, collect([$tenantA, $tenantB])))->toBeTrue();
|
expect($enforcement->bulkSelectionIsAuthorized($user, collect([$tenantA, $tenantB])))->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user