TenantAtlas/tests/Feature/Onboarding/OnboardingVerificationAssistTest.php
ahmido 5ec62cd117 feat: harden livewire trusted state boundaries (#182)
## Summary
- add the shared trusted-state model and resolver helpers for first-slice Livewire and Filament surfaces
- harden managed tenant onboarding, tenant required permissions, and system runbooks against forged or stale public state
- add focused Pest guard and regression coverage plus the complete spec 152 artifact set

## Validation
- `vendor/bin/sail artisan test --compact`
- manual smoke validated on `/admin/onboarding/{onboardingDraft}`
- manual smoke validated on `/admin/tenants/{tenant}/required-permissions`
- manual smoke validated on `/system/ops/runbooks`

## Notes
- Livewire v4.0+ / Filament v5 stack unchanged
- no new panels, routes, assets, or global-search changes
- provider registration remains in `bootstrap/providers.php`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #182
2026-03-18 23:01:14 +00:00

289 lines
11 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantPermission;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Verification\VerificationReportWriter;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
function assistConfiguredPermissionKeys(): array
{
$configured = array_merge(
config('intune_permissions.permissions', []),
config('entra_permissions.permissions', []),
);
return array_values(array_filter(array_map(static function (mixed $permission): ?string {
if (! is_array($permission)) {
return null;
}
$key = $permission['key'] ?? null;
return is_string($key) && trim($key) !== '' ? trim($key) : null;
}, $configured)));
}
function seedAssistPermissionInventory(
Tenant $tenant,
?string $missingKey = null,
?int $staleDays = null,
array $errorKeys = [],
): void {
foreach (assistConfiguredPermissionKeys() as $key) {
if ($missingKey !== null && $key === $missingKey) {
continue;
}
TenantPermission::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'permission_key' => $key,
'status' => in_array($key, $errorKeys, true) ? 'error' : 'granted',
'details' => in_array($key, $errorKeys, true) ? ['reason_code' => 'permission_denied'] : ['source' => 'db'],
'last_checked_at' => $staleDays === null ? now() : now()->subDays($staleDays),
]);
}
}
/**
* @return array{0:User,1:Tenant,2:\App\Models\TenantOnboardingSession,3:ProviderConnection,4:OperationRun,5:?string}
*/
function createVerificationAssistDraft(
string $state = 'blocked',
string $workspaceRole = 'owner',
string $tenantRole = 'owner',
bool $staleVerificationRun = false,
): array {
[$user, $tenant] = createUserWithTenant(
role: $tenantRole,
workspaceRole: $workspaceRole,
ensureDefaultMicrosoftProviderConnection: false,
);
$workspace = $tenant->workspace()->firstOrFail();
$verifiedConnection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Verified connection',
'is_default' => true,
'status' => 'connected',
]);
$selectedConnection = $verifiedConnection;
if ($staleVerificationRun) {
$selectedConnection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'dummy',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Current selected connection',
'is_default' => false,
'status' => 'connected',
]);
}
$allPermissionKeys = assistConfiguredPermissionKeys();
$missingKey = $allPermissionKeys[0] ?? null;
$errorKeys = [];
$staleDays = null;
$check = [];
$outcome = OperationRunOutcome::Succeeded->value;
if ($state === 'blocked') {
seedAssistPermissionInventory($tenant, missingKey: $missingKey);
$check = [
'key' => 'permissions.admin_consent',
'title' => 'Required application permissions',
'status' => 'fail',
'severity' => 'critical',
'blocking' => true,
'reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
'message' => 'Missing required Graph permissions.',
'evidence' => [],
'next_steps' => [],
];
$outcome = OperationRunOutcome::Blocked->value;
} elseif ($state === 'needs_attention') {
seedAssistPermissionInventory($tenant, staleDays: 45);
$check = [
'key' => 'permissions.admin_consent',
'title' => 'Required application permissions',
'status' => 'warn',
'severity' => 'medium',
'blocking' => false,
'reason_code' => ProviderReasonCodes::ProviderPermissionRefreshFailed,
'message' => 'Stored permission data needs review.',
'evidence' => [],
'next_steps' => [],
];
} elseif ($state === 'degraded') {
$errorKeys = array_slice($allPermissionKeys, 0, 1);
seedAssistPermissionInventory($tenant, staleDays: 45, errorKeys: $errorKeys);
$check = [
'key' => 'permissions.admin_consent',
'title' => 'Required application permissions',
'status' => 'warn',
'severity' => 'medium',
'blocking' => false,
'reason_code' => ProviderReasonCodes::ProviderPermissionRefreshFailed,
'message' => 'Stored permission data needs review.',
'evidence' => [],
'next_steps' => [],
];
} else {
seedAssistPermissionInventory($tenant);
$check = [
'key' => 'provider.connection.check',
'title' => 'Provider connection check',
'status' => 'pass',
'severity' => 'info',
'blocking' => false,
'reason_code' => 'ok',
'message' => 'Connection is healthy.',
'evidence' => [],
'next_steps' => [],
];
}
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => $outcome,
'context' => [
'provider_connection_id' => (int) $verifiedConnection->getKey(),
'target_scope' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'entra_tenant_name' => (string) $tenant->name,
],
'verification_report' => VerificationReportWriter::build('provider.connection.check', [$check]),
],
]);
$draft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'verify',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $selectedConnection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
return [$user, $tenant, $draft, $selectedConnection, $run, $missingKey];
}
it('shows the assist trigger for blocked and needs-attention states and hides it when verification is ready', function (string $state, bool $shouldSeeTrigger): void {
[$user, , $draft] = createVerificationAssistDraft($state);
$component = Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $draft->getKey()]);
if ($shouldSeeTrigger) {
$component->assertActionVisible('wizardVerificationRequiredPermissionsAssist');
return;
}
$component->assertActionHidden('wizardVerificationRequiredPermissionsAssist');
})->with([
'blocked' => ['blocked', true],
'needs attention' => ['needs_attention', true],
'ready' => ['ready', false],
]);
it('opens and closes the assist slideover without changing the verify step', function (): void {
[$user, , $draft] = createVerificationAssistDraft('blocked');
$existingRunCount = OperationRun::query()->count();
$existingAuditCount = AuditLog::query()->count();
session()->put(WorkspaceContext::SESSION_KEY, (int) $user->last_workspace_id);
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $draft->getKey()])
->mountAction('wizardVerificationRequiredPermissionsAssist')
->assertMountedActionModalSee('Required permissions assist')
->assertMountedActionModalSee('Open full page')
->unmountAction();
expect($draft->fresh()?->current_step)->toBe('verify')
->and(OperationRun::query()->count())->toBe($existingRunCount)
->and(AuditLog::query()->count())->toBe($existingAuditCount);
});
it('renders summary metadata and missing application permissions in the assist slideover', function (): void {
[$user, , $draft, , , $missingKey] = createVerificationAssistDraft('blocked');
session()->put(WorkspaceContext::SESSION_KEY, (int) $user->last_workspace_id);
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $draft->getKey()])
->mountAction('wizardVerificationRequiredPermissionsAssist')
->assertMountedActionModalSee('Missing application permissions')
->assertMountedActionModalSee('Copy missing application permissions')
->assertMountedActionModalSee((string) $missingKey)
->assertMountedActionModalDontSee('Copy missing delegated permissions');
});
it('renders degraded fallback and hides unavailable copy actions when stored detail is incomplete', function (): void {
[$user, , $draft] = createVerificationAssistDraft('degraded', staleVerificationRun: true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $user->last_workspace_id);
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $draft->getKey()])
->mountAction('wizardVerificationRequiredPermissionsAssist')
->assertMountedActionModalSee('Verification result is stale')
->assertMountedActionModalSee('Stored permission data needs refresh')
->assertMountedActionModalSee('Compact detail is incomplete')
->assertMountedActionModalDontSee('Copy missing application permissions')
->assertMountedActionModalDontSee('Copy missing delegated permissions');
});
it('returns 404 for workspace members who are out of scope for the tenant assist surface', function (): void {
[$authorizedUser, $tenant, $draft] = createVerificationAssistDraft('blocked');
$workspace = $tenant->workspace()->firstOrFail();
$outOfScopeUser = User::factory()->create();
WorkspaceMembership::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $outOfScopeUser->getKey(),
'role' => 'owner',
]);
$this->actingAs($outOfScopeUser)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]))
->assertNotFound();
});