TenantAtlas/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php
ahmido ab6eccaf40
Some checks failed
Main Confidence / confidence (push) Failing after 48s
feat: add onboarding readiness workflow (#277)
## Summary
- add derived onboarding readiness to the managed tenant onboarding workflow and multi-draft picker
- keep provider-specific permission diagnostics secondary while preserving canonical `Open operation` and existing onboarding action semantics
- add spec-kit artifacts for `240-tenant-onboarding-readiness` and align roadmap/spec-candidate planning notes
- unify the required-permissions empty state copy to English

## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/RequiredPermissions/RequiredPermissionsEmptyStateTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- browser smoke exercised the onboarding picker, route-bound mismatch readiness state, canonical `Open operation` path, and local fixture cleanup

## Notes
- branch includes the generated spec artifacts under `specs/240-tenant-onboarding-readiness/`
- temporary browser smoke tenants/drafts/runs were cleaned from the local environment after validation

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #277
2026-04-25 21:17:31 +00:00

2268 lines
83 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\TenantOnboardingSession;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Audit\AuditActionId;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Verification\VerificationReportWriter;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Testing\TestAction;
use Livewire\Livewire;
function managedReadinessPermissionKeys(): 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 seedManagedReadinessPermissions(Tenant $tenant, ?int $staleDays = null, ?string $missingKey = null): ?string
{
$keys = managedReadinessPermissionKeys();
$missingKey ??= $keys[0] ?? null;
foreach ($keys 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' => 'granted',
'details' => ['source' => 'readiness-test'],
'last_checked_at' => $staleDays === null ? now() : now()->subDays($staleDays),
]);
}
return $missingKey;
}
/**
* @return array{0: User, 1: TenantOnboardingSession, 2: ProviderConnection, 3: OperationRun|null, 4: string|null}
*/
function createManagedReadinessBlockerDraft(string $state): array
{
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->onboarding()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => fake()->uuid(),
'name' => 'Blocker Tenant '.str_replace('_', ' ', $state),
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$connectionState = [
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Blocker connection',
'is_default' => true,
];
if ($state === 'missing_consent') {
$connectionState['consent_status'] = ProviderConsentStatus::Required->value;
}
if ($state === 'revoked_consent') {
$connectionState['consent_status'] = ProviderConsentStatus::Revoked->value;
}
if ($state === 'disabled_connection') {
$connectionState['is_enabled'] = false;
$connectionState['consent_status'] = ProviderConsentStatus::Granted->value;
}
$connection = ProviderConnection::factory()->platform()->create($connectionState);
$run = null;
$missingKey = null;
if ($state === 'blocked_verification' || $state === 'permission_gap') {
$connection->forceFill([
'is_enabled' => true,
'consent_status' => ProviderConsentStatus::Granted->value,
])->save();
$missingKey = seedManagedReadinessPermissions($tenant);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Blocked->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'permissions.admin_consent',
'title' => 'Required application permissions',
'status' => 'fail',
'severity' => 'critical',
'blocking' => true,
'reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
'message' => 'Missing required provider permissions.',
'evidence' => [],
'next_steps' => [],
],
]),
],
]);
}
$draft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'verify',
'state' => array_filter([
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => $run instanceof OperationRun ? (int) $run->getKey() : null,
], static fn (mixed $value): bool => $value !== null),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
return [$user, $draft, $connection, $run, $missingKey];
}
it('returns 404 for non-members when starting onboarding with a selected workspace', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user)
->get('/admin/onboarding')
->assertNotFound();
});
it('forbids workspace members without onboarding capability from loading the wizard or executing actions', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'readonly',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user)
->get('/admin/onboarding')
->assertForbidden();
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class)
->assertForbidden();
expect(Tenant::query()->count())->toBe(0);
expect(TenantOnboardingSession::query()->count())->toBe(0);
});
it('renders onboarding wizard for workspace owners and allows identifying a managed tenant', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user)
->get('/admin/onboarding')
->assertSuccessful();
$entraTenantId = '22222222-2222-2222-2222-222222222222';
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class)
->call('identifyManagedTenant', [
'entra_tenant_id' => $entraTenantId,
'environment' => 'prod',
'name' => 'Acme',
'primary_domain' => 'acme.example',
'notes' => 'Initial onboarding',
]);
$tenant = Tenant::query()->where('tenant_id', $entraTenantId)->firstOrFail();
expect((int) $tenant->workspace_id)->toBe((int) $workspace->getKey());
expect($tenant->status)->toBe(Tenant::STATUS_ONBOARDING);
$this->assertDatabaseHas('tenant_memberships', [
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$this->assertDatabaseHas('managed_tenant_onboarding_sessions', [
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => $entraTenantId,
'current_step' => 'identify',
]);
});
it('renders neutral tenant id placeholder guidance for onboarding input', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user)
->get('/admin/onboarding')
->assertSuccessful()
->assertSee('Tenant ID (GUID)')
->assertSee('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', false);
});
it('uses target-scope wording in the onboarding provider setup step', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$entraTenantId = '34343434-3434-3434-3434-343434343434';
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class)
->call('identifyManagedTenant', [
'entra_tenant_id' => $entraTenantId,
'environment' => 'prod',
'name' => 'Target Scope Tenant',
])
->set('data.connection_mode', 'new')
->assertSee('Target scope ID')
->assertSee('The provider connection will point to this tenant target scope.')
->assertSee($entraTenantId)
->assertDontSee('Directory (tenant) ID')
->assertDontSee('Graph API calls');
});
it('renders review summary guidance and activation consequences for ready onboarding sessions', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$entraTenantId = 'abababab-abab-abab-abab-abababababab';
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => $entraTenantId,
'status' => Tenant::STATUS_ONBOARDING,
'name' => 'Activation Ready Tenant',
]);
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $entraTenantId,
'display_name' => 'Platform onboarding connection',
'is_default' => true,
'is_enabled' => true,
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => $entraTenantId,
'entra_tenant_name' => 'Activation Ready Tenant',
],
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'consent',
'title' => 'Required application permissions',
'status' => 'pass',
'severity' => 'low',
'blocking' => false,
'reason_code' => 'ok',
'message' => 'Consent is ready.',
'evidence' => [],
'next_steps' => [],
],
]),
],
]);
TenantOnboardingSession::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => $entraTenantId,
'current_step' => 'bootstrap',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
$this->actingAs($user)
->followingRedirects()
->get('/admin/onboarding')
->assertSuccessful()
->assertSee('Skipped - No bootstrap actions selected')
->assertSee('Tenant status will be set to Active.')
->assertSee('The provider connection will be used for provider API calls.');
});
it('renders selected bootstrap actions in the review summary before any bootstrap run starts', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$entraTenantId = 'cdcdcdcd-cdcd-cdcd-cdcd-cdcdcdcdcdcd';
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => $entraTenantId,
'status' => Tenant::STATUS_ONBOARDING,
'name' => 'Bootstrap Selected Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $entraTenantId,
'display_name' => 'Platform onboarding connection',
'is_default' => true,
'is_enabled' => true,
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => $entraTenantId,
'entra_tenant_name' => 'Bootstrap Selected Tenant',
],
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'consent',
'title' => 'Required application permissions',
'status' => 'pass',
'severity' => 'low',
'blocking' => false,
'reason_code' => 'ok',
'message' => 'Consent is ready.',
'evidence' => [],
'next_steps' => [],
],
]),
],
]);
$session = TenantOnboardingSession::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => $entraTenantId,
'current_step' => 'complete',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
'bootstrap_operation_types' => ['inventory_sync', 'compliance.snapshot'],
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
$component = Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $session->getKey()]);
$component
->assertDontSee('Bootstrap needs attention')
->assertDontSee('Selected bootstrap actions must complete before activation. Return to Bootstrap to remove the selected actions and skip this optional step, or resolve the required permission and start the blocked action again.');
$summaryMethod = new \ReflectionMethod($component->instance(), 'completionSummaryBootstrapSummary');
$summaryMethod->setAccessible(true);
expect($summaryMethod->invoke($component->instance()))->toBe('Selected - 2 action(s) selected');
});
it('renders blocked bootstrap runs as action required in the review summary', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$entraTenantId = 'efefefef-efef-efef-efef-efefefefefef';
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => $entraTenantId,
'status' => Tenant::STATUS_ONBOARDING,
'name' => 'Bootstrap Blocked Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $entraTenantId,
'display_name' => 'Platform onboarding connection',
'is_default' => true,
'is_enabled' => true,
]);
$verificationRun = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => $entraTenantId,
'entra_tenant_name' => 'Bootstrap Blocked Tenant',
],
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'consent',
'title' => 'Required application permissions',
'status' => 'pass',
'severity' => 'low',
'blocking' => false,
'reason_code' => 'ok',
'message' => 'Consent is ready.',
'evidence' => [],
'next_steps' => [],
],
]),
],
]);
$bootstrapRun = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'inventory.sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Blocked->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'reason_translation' => [
'operator_label' => 'Permission required',
],
],
]);
$session = TenantOnboardingSession::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => $entraTenantId,
'current_step' => 'complete',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $verificationRun->getKey(),
'bootstrap_operation_types' => ['inventory_sync', 'compliance.snapshot'],
'bootstrap_operation_runs' => ['inventory_sync' => (int) $bootstrapRun->getKey()],
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
$component = Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $session->getKey()]);
$summaryMethod = new \ReflectionMethod($component->instance(), 'completionSummaryBootstrapSummary');
$summaryMethod->setAccessible(true);
expect($summaryMethod->invoke($component->instance()))->toBe('Action required - Permission required');
});
it('initializes entangled wizard state keys to avoid Livewire entangle errors', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class)
->assertSet('data.notes', '')
->assertSet('data.bootstrap_operation_types', [])
->assertSet('data.override_blocked', false)
->assertSet('data.override_reason', '');
});
it('persists selected bootstrap actions in the onboarding draft state', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$entraTenantId = 'dededede-dede-dede-dede-dededededede';
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => $entraTenantId,
'status' => Tenant::STATUS_ONBOARDING,
'name' => 'Persist Bootstrap Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $entraTenantId,
'display_name' => 'Platform onboarding connection',
'is_default' => true,
'is_enabled' => true,
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'consent',
'title' => 'Required application permissions',
'status' => 'pass',
'severity' => 'low',
'blocking' => false,
'reason_code' => 'ok',
'message' => 'Consent is ready.',
'evidence' => [],
'next_steps' => [],
],
]),
],
]);
$session = TenantOnboardingSession::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => $entraTenantId,
'current_step' => 'bootstrap',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
$component = Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $session->getKey()]);
$persistMethod = new \ReflectionMethod($component->instance(), 'persistBootstrapSelection');
$persistMethod->setAccessible(true);
$persistMethod->invoke($component->instance(), ['inventory_sync', 'compliance.snapshot']);
$session->refresh();
expect($session->state['bootstrap_operation_types'] ?? null)->toBe(['inventory.sync', 'compliance.snapshot']);
});
it('filters unsupported bootstrap selections from persisted onboarding drafts', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$tenantGuid = '12121212-1212-1212-1212-121212121212';
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => $tenantGuid,
'status' => Tenant::STATUS_ONBOARDING,
'name' => 'Acme',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $tenantGuid,
'display_name' => 'Platform onboarding connection',
'is_default' => true,
'is_enabled' => true,
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => $tenantGuid,
'entra_tenant_name' => 'Acme',
],
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'consent',
'title' => 'Required application permissions',
'status' => 'pass',
'severity' => 'low',
'blocking' => false,
'reason_code' => 'ok',
'message' => 'Consent is ready.',
'evidence' => [],
'next_steps' => [],
],
]),
],
]);
$session = TenantOnboardingSession::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => $tenantGuid,
'current_step' => 'complete',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
'bootstrap_operation_types' => [
'inventory_sync',
'compliance.snapshot',
'restore.execute',
'entra_group_sync',
'directory_role_definitions.sync',
],
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
$component = Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $session->getKey()]);
$normalizeMethod = new \ReflectionMethod($component->instance(), 'normalizeBootstrapOperationTypes');
$normalizeMethod->setAccessible(true);
expect($normalizeMethod->invoke($component->instance(), [
'inventory_sync',
'compliance.snapshot',
'restore.execute',
'entra_group_sync',
'directory_role_definitions.sync',
]))->toBe(['inventory.sync', 'compliance.snapshot']);
$optionsMethod = new \ReflectionMethod($component->instance(), 'bootstrapOperationOptions');
$optionsMethod->setAccessible(true);
expect(array_keys($optionsMethod->invoke($component->instance())))->toBe(['inventory.sync', 'compliance.snapshot']);
});
it('returns resumable drafts with missing provider connections to the provider connection step', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ONBOARDING,
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$draft = TenantOnboardingSession::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => (string) $tenant->tenant_id,
'current_step' => 'verify',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => 999999,
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $draft->getKey()])
->assertWizardCurrentStep(2)
->assertSet('selectedProviderConnectionId', null)
->assertSet('data.provider_connection_id', null);
});
it('allows workspace owners to create a dedicated override connection explicitly during onboarding', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$entraTenantId = '55555555-5555-5555-5555-555555555555';
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class);
$component->call('identifyManagedTenant', [
'entra_tenant_id' => $entraTenantId,
'environment' => 'prod',
'name' => 'Dedicated Tenant',
]);
$component
->set('data.connection_mode', 'new')
->assertSee('Dedicated override')
->call('createProviderConnection', [
'display_name' => 'Dedicated onboarding connection',
'connection_type' => ProviderConnectionType::Dedicated->value,
'client_id' => 'dedicated-onboarding-client',
'client_secret' => 'dedicated-onboarding-secret',
'is_default' => true,
]);
$tenant = Tenant::query()->where('tenant_id', $entraTenantId)->firstOrFail();
$connection = \App\Models\ProviderConnection::query()
->where('workspace_id', (int) $workspace->getKey())
->where('tenant_id', (int) $tenant->getKey())
->where('provider', 'microsoft')
->firstOrFail();
expect($connection->connection_type)->toBe(ProviderConnectionType::Dedicated)
->and($connection->credential)->not->toBeNull()
->and($connection->credential?->payload['client_id'] ?? null)->toBe('dedicated-onboarding-client');
});
it('forbids workspace managers from creating dedicated override connections during onboarding', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'manager',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$entraTenantId = '66666666-6666-6666-6666-666666666666';
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class);
$component->call('identifyManagedTenant', [
'entra_tenant_id' => $entraTenantId,
'environment' => 'prod',
'name' => 'Managed Tenant',
]);
$component
->assertDontSee('Dedicated override')
->call('createProviderConnection', [
'display_name' => 'Forbidden dedicated connection',
'connection_type' => ProviderConnectionType::Dedicated->value,
'client_id' => 'forbidden-dedicated-client',
'client_secret' => 'forbidden-dedicated-secret',
'is_default' => true,
])
->assertForbidden();
});
it('is idempotent when identifying the same Entra tenant ID twice', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$entraTenantId = '33333333-3333-3333-3333-333333333333';
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class);
$component->call('identifyManagedTenant', [
'entra_tenant_id' => $entraTenantId,
'environment' => 'prod',
'name' => 'Acme',
]);
$component->call('identifyManagedTenant', [
'entra_tenant_id' => $entraTenantId,
'environment' => 'prod',
'name' => 'Acme',
]);
expect(Tenant::query()->where('tenant_id', $entraTenantId)->count())->toBe(1);
expect(TenantOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('entra_tenant_id', $entraTenantId)
->whereNull('completed_at')
->count())->toBe(1);
});
it('returns 404 and does not create anything when entra_tenant_id exists in another workspace', function (): void {
$entraTenantId = '44444444-4444-4444-4444-444444444444';
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceA->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceB->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
Tenant::factory()->create([
'workspace_id' => (int) $workspaceA->getKey(),
'tenant_id' => $entraTenantId,
'status' => Tenant::STATUS_ACTIVE,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspaceB->getKey());
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class)
->call('identifyManagedTenant', [
'entra_tenant_id' => $entraTenantId,
'environment' => 'prod',
'name' => 'Other Workspace',
])
->assertStatus(404);
});
it('returns 404 for legacy onboarding entry points', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$this->get('/admin/register-tenant')->assertNotFound();
$this->get('/admin/managed-tenants')->assertNotFound();
$this->get('/admin/managed-tenants/onboarding')->assertNotFound();
$this->get('/admin/new')->assertNotFound();
});
it('shows resume context and derived stage when loading a concrete draft route', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create(['name' => 'Draft Owner']);
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$draft = createOnboardingDraft([
'workspace' => $workspace,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'connection',
'state' => [
'entra_tenant_id' => '77777777-7777-7777-7777-777777777777',
'tenant_name' => 'Resume Draft',
'provider_connection_id' => 42,
],
]);
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, [
'onboardingDraft' => (int) $draft->getKey(),
])
->assertSuccessful()
->assertSee('Onboarding draft')
->assertSee('Resume Draft')
->assertSee('Verify access')
->assertSee('Draft Owner');
});
it('shows route-bound readiness progress and check-not-run guidance with one primary next action', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create(['name' => 'Readiness Owner']);
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->onboarding()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => '31313131-3131-3131-3131-313131313131',
'name' => 'No Check Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$connection = 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' => 'No check connection',
'is_default' => true,
]);
$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) $connection->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$response = $this->actingAs($user)
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
->assertSuccessful()
->assertSee('Onboarding readiness')
->assertSee('Current checkpoint')
->assertSee('Verify access')
->assertSee('Verification has not run yet')
->assertSee('Provider connection')
->assertSee('Primary next action')
->assertSee('Start verification');
expect(substr_count($response->getContent(), 'Primary next action'))->toBe(1);
});
it('shows route-bound ready readiness with freshness and canonical operation evidence', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->onboarding()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => '32323232-3232-3232-3232-323232323232',
'name' => 'Ready Readiness Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
seedManagedReadinessPermissions($tenant, missingKey: '__none__');
$connection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Ready connection',
'is_default' => true,
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_report' => VerificationReportWriter::build('provider.connection.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' => [],
],
]),
],
]);
$draft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'complete',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user)
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
->assertSuccessful()
->assertSee('Ready for activation')
->assertSee('Verification and permission evidence are current.')
->assertSee('Complete onboarding')
->assertSee('Supporting evidence')
->assertSee('Open operation')
->assertSee(route('admin.operations.view', ['run' => $run->getKey()]), false);
});
it('classifies consent, disabled connection, and blocked verification readiness blockers', function (string $state, string $summary, string $nextAction): void {
[$user, $draft] = createManagedReadinessBlockerDraft($state);
$this->actingAs($user)
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
->assertSuccessful()
->assertSee('Onboarding readiness')
->assertSee($summary)
->assertSee($nextAction);
})->with([
'missing consent' => ['missing_consent', 'Provider consent required', 'Grant consent'],
'revoked consent' => ['revoked_consent', 'Provider consent revoked', 'Grant consent'],
'disabled connection' => ['disabled_connection', 'Provider connection disabled', 'Review provider connection'],
'blocked verification' => ['blocked_verification', 'Permission or consent blocker needs attention', 'Review permissions'],
]);
it('keeps permission gap diagnostics provider-owned while top-level readiness stays neutral', function (): void {
[$user, $draft, , , $missingKey] = createManagedReadinessBlockerDraft('permission_gap');
$response = $this->actingAs($user)
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
->assertSuccessful()
->assertSee('Permission or consent blocker needs attention')
->assertSee('Permission diagnostics')
->assertSee('Missing application permissions')
->assertSee('Review permissions');
if (is_string($missingKey) && $missingKey !== '') {
$response->assertSee($missingKey);
}
$response->assertDontSee('Microsoft Graph readiness');
});
it('downgrades route-bound readiness when permission evidence is stale and keeps the operation link canonical', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->onboarding()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => '52525252-5252-5252-5252-525252525252',
'name' => 'Stale Evidence Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
seedManagedReadinessPermissions($tenant, staleDays: 45, missingKey: '__none__');
$connection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Stale readiness connection',
'is_default' => true,
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_report' => VerificationReportWriter::build('provider.connection.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' => [],
],
]),
],
]);
$draft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'complete',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user)
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
->assertSuccessful()
->assertSee('Readiness needs attention')
->assertSee('Permission data is older than the 30-day freshness window.')
->assertSee('Rerun verification')
->assertSee('Open operation')
->assertSee(route('admin.operations.view', ['run' => $run->getKey()]), false);
});
it('downgrades route-bound readiness when verification evidence belongs to another selected connection', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->onboarding()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => '53535353-5353-5353-5353-535353535353',
'name' => 'Mismatched Evidence Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
seedManagedReadinessPermissions($tenant, missingKey: '__none__');
$oldConnection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => '54545454-5454-5454-5454-545454545454',
'display_name' => 'Previous connection',
]);
$selectedConnection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Selected connection',
'is_default' => true,
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => (int) $oldConnection->getKey(),
'verification_report' => VerificationReportWriter::build('provider.connection.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' => [],
],
]),
],
]);
$draft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'complete',
'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());
$this->actingAs($user)
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
->assertSuccessful()
->assertSee('Verification needs refresh')
->assertSee('Verification evidence belongs to a different provider connection.')
->assertSee('Rerun verification')
->assertSee('Open operation')
->assertSee(route('admin.operations.view', ['run' => $run->getKey()]), false);
});
it('resumes an existing draft for the same tenant instead of creating a duplicate', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$existingDraft = createOnboardingDraft([
'workspace' => $workspace,
'started_by' => $user,
'updated_by' => $user,
'entra_tenant_id' => '88888888-8888-8888-8888-888888888888',
'state' => [
'entra_tenant_id' => '88888888-8888-8888-8888-888888888888',
'tenant_name' => 'Existing Draft Tenant',
],
]);
createOnboardingDraft([
'workspace' => $workspace,
'started_by' => $user,
'updated_by' => $user,
'entra_tenant_id' => '12121212-1212-1212-1212-121212121212',
'state' => [
'entra_tenant_id' => '12121212-1212-1212-1212-121212121212',
'tenant_name' => 'Keep Landing Stable',
],
]);
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class)
->call('identifyManagedTenant', [
'entra_tenant_id' => '88888888-8888-8888-8888-888888888888',
'environment' => 'prod',
'name' => 'Existing Draft Tenant',
])
->assertRedirect(route('admin.onboarding.draft', ['onboardingDraft' => $existingDraft->getKey()]));
expect(TenantOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('entra_tenant_id', '88888888-8888-8888-8888-888888888888')
->count())->toBe(1);
});
it('allows resuming a selected draft from the picker', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$draftToResume = createOnboardingDraft([
'workspace' => $workspace,
'started_by' => $user,
'updated_by' => $user,
'state' => [
'entra_tenant_id' => '99999999-9999-9999-9999-999999999999',
'tenant_name' => 'Resume Me',
],
]);
createOnboardingDraft([
'workspace' => $workspace,
'started_by' => $user,
'updated_by' => $user,
'state' => [
'entra_tenant_id' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'tenant_name' => 'Leave Me',
],
]);
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class)
->callAction(
TestAction::make('resume_draft_'.$draftToResume->getKey())
->schemaComponent('draft_picker_actions_'.$draftToResume->getKey())
)
->assertRedirect(route('admin.onboarding.draft', ['onboardingDraft' => $draftToResume->getKey()]));
});
/*
|--------------------------------------------------------------------------
| Legacy onboarding suite (deprecated)
|--------------------------------------------------------------------------
|
| The remainder of this file previously contained an end-to-end onboarding
| suite that relied on deprecated routes and pre-enterprise state semantics.
| Spec 073 replaces it with focused coverage under tests/Feature/Onboarding
| and tests/Feature/Rbac.
|
| Keeping the legacy assertions around (commented) is intentional to avoid
| reintroducing removed routes or old semantics.
$this->actingAs($user);
$tenantGuid = '22222222-2222-2222-2222-222222222222';
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
expect(Tenant::query()->where('tenant_id', $tenantGuid)->count())->toBe(1);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
expect(TenantOnboardingSession::query()
->where('workspace_id', $workspace->getKey())
->where('tenant_id', $tenant->getKey())
->count())->toBe(1);
$this->assertDatabaseHas('managed_tenant_onboarding_sessions', [
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
]);
});
it('returns 404 and does not create anything when tenant_id exists in another workspace', function (): void {
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspaceB->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$tenantGuid = '33333333-3333-3333-3333-333333333333';
Tenant::factory()->create([
'workspace_id' => $workspaceA->getKey(),
'tenant_id' => $tenantGuid,
'name' => 'Acme',
'status' => 'active',
]);
$this->actingAs($user);
Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspaceB])
->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme'])
->assertStatus(404);
expect(Tenant::query()->where('tenant_id', $tenantGuid)->count())->toBe(1);
expect(TenantOnboardingSession::query()
->where('workspace_id', $workspaceB->getKey())
->whereIn('tenant_id', Tenant::query()->where('tenant_id', $tenantGuid)->pluck('id'))
->count())->toBe(0);
});
it('binds an unscoped existing tenant to the current workspace when safe and allows identifying it', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$tenantGuid = '77777777-7777-7777-7777-777777777777';
$tenant = Tenant::factory()->create([
'workspace_id' => null,
'tenant_id' => $tenantGuid,
'name' => 'Acme',
'status' => 'active',
]);
\App\Models\TenantMembership::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
'source' => 'manual',
'created_by_user_id' => (int) $user->getKey(),
]);
$this->assertDatabaseHas('tenants', [
'id' => (int) $tenant->getKey(),
'workspace_id' => null,
'tenant_id' => $tenantGuid,
]);
$this->assertDatabaseHas('tenant_memberships', [
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
]);
$this->actingAs($user);
Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace])
->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme'])
->assertOk();
$this->assertDatabaseHas('tenants', [
'id' => (int) $tenant->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => $tenantGuid,
]);
});
it('auto-selects the default provider connection and allows switching', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$tenantGuid = '99999999-9999-9999-9999-999999999999';
$tenant = Tenant::factory()->create([
'workspace_id' => $workspace->getKey(),
'tenant_id' => $tenantGuid,
'name' => 'Acme',
'status' => 'pending',
]);
$default = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $tenantGuid,
'display_name' => 'Default',
'is_default' => true,
]);
$other = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft_alt',
'entra_tenant_id' => $tenantGuid,
'display_name' => 'Other',
'is_default' => false,
]);
$this->actingAs($user);
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$component->assertSet('selectedProviderConnectionId', (int) $default->getKey());
$session = TenantOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('tenant_id', (int) $tenant->getKey())
->firstOrFail();
expect($session->state['provider_connection_id'] ?? null)->toBe((int) $default->getKey());
$component->call('selectProviderConnection', (int) $other->getKey());
$component->assertSet('selectedProviderConnectionId', (int) $other->getKey());
$session->refresh();
expect($session->state['provider_connection_id'] ?? null)->toBe((int) $other->getKey());
});
it('dedupes verification runs: starting verification twice returns the active run and dispatches only once', function (): void {
Bus::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
$tenantGuid = '77777777-7777-7777-7777-777777777777';
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'is_default' => true,
]);
$component->set('selectedProviderConnectionId', (int) $connection->getKey());
$component->call('startVerification');
$component->call('startVerification');
$component
->assertSee('Onboarding readiness')
->assertSee('Open operation');
expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'provider.connection.check')
->count())->toBe(1);
Bus::assertDispatchedTimes(\App\Jobs\ProviderConnectionHealthCheckJob::class, 1);
$this->assertDatabaseHas('audit_logs', [
'workspace_id' => (int) $workspace->getKey(),
'actor_id' => (int) $user->getKey(),
'action' => 'managed_tenant_onboarding.verification_start',
'resource_type' => 'operation_run',
]);
$session = TenantOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('tenant_id', (int) $tenant->getKey())
->firstOrFail();
expect($session->state['verification_operation_run_id'] ?? null)->not->toBeNull();
});
it('creates a provider connection with encrypted credentials and does not persist secrets in session state', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
$tenantGuid = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
$secret = 'super-secret-123';
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component
->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme'])
->call('createProviderConnection', [
'display_name' => 'Onboarding Connection',
'client_id' => 'client-id-1',
'client_secret' => $secret,
'is_default' => true,
])
->assertDontSee($secret);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$connection = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->where('provider', 'microsoft')
->where('entra_tenant_id', $tenantGuid)
->firstOrFail();
expect($connection->credential)->toBeInstanceOf(ProviderCredential::class);
expect($connection->credential->toArray())->not->toHaveKey('payload');
$session = TenantOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('tenant_id', (int) $tenant->getKey())
->firstOrFail();
$state = $session->state ?? [];
expect($state)->not->toHaveKey('client_id');
expect($state)->not->toHaveKey('client_secret');
expect($state['provider_connection_id'] ?? null)->toBe((int) $connection->getKey());
});
it('starts verification, creates an operation run, dispatches the job, and does not include secrets in run context', function (): void {
Bus::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
$tenantGuid = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $tenantGuid,
'is_default' => true,
]);
$component->set('selectedProviderConnectionId', (int) $connection->getKey());
$component->call('startVerification');
$run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'provider.connection.check')
->latest('id')
->firstOrFail();
expect($run->context)->toBeArray();
expect($run->context['provider_connection_id'] ?? null)->toBe((int) $connection->getKey());
expect($run->context)->not->toHaveKey('client_id');
expect($run->context)->not->toHaveKey('client_secret');
Bus::assertDispatched(\App\Jobs\ProviderConnectionHealthCheckJob::class);
});
it('can resume the latest onboarding session as a different authorized workspace member', function (): void {
Bus::fake();
$workspace = Workspace::factory()->create();
$initiator = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $initiator->getKey(),
'role' => 'owner',
]);
$resumer = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $resumer->getKey(),
'role' => 'manager',
]);
$tenantGuid = 'cccccccc-cccc-cccc-cccc-cccccccccccc';
$this->actingAs($initiator);
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $tenantGuid,
'is_default' => true,
]);
$session = TenantOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('tenant_id', (int) $tenant->getKey())
->firstOrFail();
$session->update([
'state' => array_merge($session->state ?? [], [
'provider_connection_id' => (int) $connection->getKey(),
]),
]);
$this->actingAs($resumer);
Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace])
->call('startVerification');
Bus::assertDispatched(\App\Jobs\ProviderConnectionHealthCheckJob::class);
});
it('completes onboarding only after verification succeeded and redirects to tenant dashboard', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
$tenantGuid = 'dddddddd-dddd-dddd-dddd-dddddddddddd';
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $tenantGuid,
'is_default' => true,
]);
$run = OperationRun::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'succeeded',
'run_identity_hash' => sha1('verify-ok-'.(string) $connection->getKey()),
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$session = TenantOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('tenant_id', (int) $tenant->getKey())
->firstOrFail();
$session->update([
'state' => array_merge($session->state ?? [], [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
]),
]);
$component
->call('completeOnboarding')
->assertRedirect(TenantDashboard::getUrl(tenant: $tenant));
$tenant->refresh();
expect($tenant->status)->toBe('active');
$session->refresh();
expect($session->completed_at)->not->toBeNull();
});
it('starts one selected bootstrap action at a time and persists the remaining selections', function (): void {
Bus::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
$tenantGuid = 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee';
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $tenantGuid,
'is_default' => true,
]);
$verificationRun = OperationRun::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'succeeded',
'run_identity_hash' => sha1('verify-ok-bootstrap-'.(string) $connection->getKey()),
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$session = TenantOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('tenant_id', (int) $tenant->getKey())
->firstOrFail();
$session->update([
'state' => array_merge($session->state ?? [], [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $verificationRun->getKey(),
]),
]);
$component->call('startBootstrap', [
'inventory_sync' => true,
'compliance.snapshot' => true,
]);
Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1);
Bus::assertNotDispatched(\App\Jobs\ProviderComplianceSnapshotJob::class);
expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'inventory.sync')
->count())->toBe(1);
expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'compliance.snapshot')
->count())->toBe(0);
$session->refresh();
$runs = $session->state['bootstrap_operation_runs'] ?? [];
expect($runs)->toBeArray();
expect($runs['inventory.sync'] ?? null)->toBeInt();
expect($runs['compliance.snapshot'] ?? null)->toBeNull();
expect($session->state['bootstrap_operation_types'] ?? null)->toBe(['inventory.sync', 'compliance.snapshot']);
$audit = AuditLog::query()
->where('action', AuditActionId::ManagedTenantOnboardingBootstrapStarted->value)
->latest('id')
->firstOrFail();
expect(data_get($audit->metadata, 'operation_types'))->toBe(['inventory.sync', 'compliance.snapshot'])
->and(data_get($audit->metadata, 'started_operation_type'))->toBe('inventory.sync');
});
it('starts the next pending bootstrap action after the prior one completes successfully', function (): void {
Bus::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
$tenantGuid = 'ffffffff-ffff-ffff-ffff-ffffffffffff';
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $tenantGuid,
'is_default' => true,
]);
$verificationRun = OperationRun::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'succeeded',
'run_identity_hash' => sha1('verify-ok-bootstrap-next-'.(string) $connection->getKey()),
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$session = TenantOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('tenant_id', (int) $tenant->getKey())
->firstOrFail();
$session->update([
'state' => array_merge($session->state ?? [], [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $verificationRun->getKey(),
]),
]);
$component->call('startBootstrap', ['inventory.sync', 'compliance.snapshot']);
$inventoryRun = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'inventory.sync')
->latest('id')
->firstOrFail();
$inventoryRun->forceFill([
'status' => 'completed',
'outcome' => 'succeeded',
])->save();
$component->call('startBootstrap', ['inventory.sync', 'compliance.snapshot']);
Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1);
Bus::assertDispatchedTimes(\App\Jobs\ProviderComplianceSnapshotJob::class, 1);
expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'compliance.snapshot')
->count())->toBe(1);
$session->refresh();
$runs = $session->state['bootstrap_operation_runs'] ?? [];
expect($runs['inventory.sync'] ?? null)->toBeInt();
expect($runs['compliance.snapshot'] ?? null)->toBeInt();
});
it('returns scope-busy semantics for verification when another run is active for the same connection scope', function (): void {
Bus::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
$tenantGuid = '88888888-8888-8888-8888-888888888888';
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'is_default' => true,
]);
OperationRun::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'inventory.sync',
'status' => 'queued',
'outcome' => 'pending',
'run_identity_hash' => sha1('busy-'.(string) $connection->getKey()),
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$component->set('selectedProviderConnectionId', (int) $connection->getKey());
$component->call('startVerification');
expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'provider.connection.check')
->count())->toBe(0);
Bus::assertNotDispatched(\App\Jobs\ProviderConnectionHealthCheckJob::class);
});
it('fails a stale queued verification run and allows starting a new verification run', function (): void {
Bus::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
$tenantGuid = '99999999-9999-9999-9999-999999999999';
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $tenantGuid,
'is_default' => true,
]);
$staleRun = OperationRun::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'provider.connection.check',
'status' => 'queued',
'outcome' => 'pending',
'run_identity_hash' => sha1('stale-queued-verify-'.(string) $connection->getKey()),
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
'created_at' => now()->subMinutes(10),
'updated_at' => now()->subMinutes(10),
]);
$component->set('selectedProviderConnectionId', (int) $connection->getKey());
$component->call('startVerification');
$staleRun->refresh();
expect($staleRun->status)->toBe('completed');
expect($staleRun->outcome)->toBe('failed');
expect($staleRun->context)->toBeArray();
expect($staleRun->context['verification_report'] ?? null)->toBeArray();
$report = $staleRun->context['verification_report'] ?? null;
expect($report['checks'] ?? null)->toBeArray();
expect($report['checks'][0]['message'] ?? null)->toBe('Run was queued but never started. A queue worker may not be running.');
$newRun = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'provider.connection.check')
->latest('id')
->firstOrFail();
expect((int) $newRun->getKey())->not->toBe((int) $staleRun->getKey());
Bus::assertDispatched(\App\Jobs\ProviderConnectionHealthCheckJob::class);
});
it('registers the onboarding capability in the canonical registry', function (): void {
expect(Capabilities::isKnown(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD))->toBeTrue();
});
it('maps onboarding capability to owner and manager workspace roles', function (): void {
expect(WorkspaceRoleCapabilityMap::hasCapability('owner', Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD))->toBeTrue();
expect(WorkspaceRoleCapabilityMap::hasCapability('manager', Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD))->toBeTrue();
expect(WorkspaceRoleCapabilityMap::hasCapability('operator', Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD))->toBeFalse();
expect(WorkspaceRoleCapabilityMap::hasCapability('readonly', Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD))->toBeFalse();
});
it('authorizes onboarding via Gate for owner and manager memberships', function (): void {
$workspace = Workspace::factory()->create();
$owner = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $owner->getKey(),
'role' => 'owner',
]);
$manager = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $manager->getKey(),
'role' => 'manager',
]);
$readonly = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $readonly->getKey(),
'role' => 'readonly',
]);
expect(Gate::forUser($owner)->allows(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD, $workspace))->toBeTrue();
expect(Gate::forUser($manager)->allows(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD, $workspace))->toBeTrue();
expect(Gate::forUser($readonly)->allows(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD, $workspace))->toBeFalse();
});
it('keeps filament tenant routing key stable (external_id resolves /admin/t/{tenant})', function (): void {
[$user, $tenant] = createUserWithTenant(
Tenant::factory()->create([
'workspace_id' => null,
'tenant_id' => '11111111-1111-1111-1111-111111111111',
]),
role: 'owner',
);
$this->actingAs($user)
->get(TenantDashboard::getUrl(tenant: $tenant))
->assertSuccessful();
$tenant->refresh();
expect($tenant->external_id)->toBe($tenant->tenant_id);
});
it('can persist a tenant onboarding session row', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$session = TenantOnboardingSession::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'current_step' => 'identify',
'state' => ['example' => 'value'],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
expect($session->exists)->toBeTrue();
$this->assertDatabaseHas('managed_tenant_onboarding_sessions', [
'id' => $session->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'current_step' => 'identify',
]);
});
*/