TenantAtlas/tests/Feature/ManagedTenantOnboardingWizardTest.php
ahmido 8e34b6084f 073-unified-managed-tenant-onboarding-wizard (#90)
Kontext / Ziel
Diese PR liefert den einzigen kanonischen Onboarding-Entry unter /admin/onboarding (workspace-first, tenantless bis zur Aktivierung) und ergänzt einen tenantless OperationRun-Viewer unter /admin/operations/{run} mit membership→404 Semantik.

Was ist enthalten?
Single entry point: /admin/onboarding ist der einzige Einstieg; Legacy Entry Points liefern echte 404 (keine Redirects).
Wizard v1 (Enterprise): idempotentes Identifizieren eines Managed Tenants (per Entra Tenant ID), resumable Session-Flow.
Provider Connection Step: Auswahl oder Erstellung, Secrets werden nie erneut gerendert / nicht in Session-State persistiert.
Verification als OperationRun: async/queued, DB-only Rendering im Wizard (keine Graph-Calls beim Rendern).
Tenantless Run Viewing: /admin/operations/{run} funktioniert ohne ausgewählten Workspace/Tenant, aber bleibt über Workspace-Mitgliedschaft autorisiert (non-member → 404).
RBAC-UX Semantik: non-member → 404, member ohne Capability → UI disabled + tooltip, server-side Action → 403.
Auditability: Aktivierung/Overrides sind auditierbar, stable action IDs, keine Secrets.
Tech / Version-Safety
Filament v5 / Livewire v4.0+ kompatibel.
Laravel 11+: Panel Provider Registrierung in providers.php (unverändert).
Tests / Format
vendor/bin/sail bin pint --dirty
Full suite: vendor/bin/sail artisan test --no-ansi → 984 passed, 5 skipped (exit 0)
Ops / Deployment Notes
Keine zusätzlichen Services vorausgesetzt.
Falls Assets registriert wurden: Deployment weiterhin mit php artisan filament:assets (wie üblich im Projekt).

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.fritz.box>
Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #90
2026-02-04 23:30:55 +00:00

830 lines
29 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
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('allows workspace members without onboarding capability to view the wizard but forbids execution', 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')
->assertSuccessful();
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class)
->call('identifyManagedTenant', [
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
'environment' => 'prod',
'name' => 'Acme',
])
->assertStatus(403);
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('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();
});
/*
|--------------------------------------------------------------------------
| 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');
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 selected bootstrap actions as separate operation runs and dispatches their jobs', 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', '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())
->whereIn('type', ['inventory.sync', 'compliance.snapshot'])
->count())->toBe(2);
$session->refresh();
$runs = $session->state['bootstrap_operation_runs'] ?? [];
expect($runs)->toBeArray();
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('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',
]);
});
*/