Implements workspace-scoped managed tenant onboarding wizard (Filament v5 / Livewire v4) with strict RBAC (404/403 semantics), resumable sessions, provider connection selection/creation, verification OperationRun, and optional bootstrap. Removes legacy onboarding entrypoints and adds Pest coverage + spec artifacts (073).
875 lines
31 KiB
PHP
875 lines
31 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Filament\Pages\TenantDashboard;
|
|
use App\Models\AuditLog;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\ProviderCredential;
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantOnboardingSession;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Models\WorkspaceMembership;
|
|
use App\Services\Auth\TenantMembershipManager;
|
|
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
|
use App\Support\Auth\Capabilities;
|
|
use Illuminate\Support\Facades\Bus;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use Livewire\Livewire;
|
|
|
|
it('returns 404 for non-members when starting onboarding', function (): void {
|
|
$workspace = Workspace::factory()->create();
|
|
$user = User::factory()->create();
|
|
|
|
$this->actingAs($user)
|
|
->get("/admin/w/{$workspace->getKey()}/managed-tenants/onboarding")
|
|
->assertNotFound();
|
|
});
|
|
|
|
it('returns 403 for workspace members without onboarding capability', function (): void {
|
|
$workspace = Workspace::factory()->create();
|
|
$user = User::factory()->create();
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => $workspace->getKey(),
|
|
'user_id' => $user->getKey(),
|
|
'role' => 'readonly',
|
|
]);
|
|
|
|
$this->actingAs($user)
|
|
->get("/admin/w/{$workspace->getKey()}/managed-tenants/onboarding")
|
|
->assertForbidden();
|
|
});
|
|
|
|
it('renders onboarding wizard for workspace owners', 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)
|
|
->get("/admin/w/{$workspace->getKey()}/managed-tenants/onboarding")
|
|
->assertSuccessful();
|
|
});
|
|
|
|
it('allows owners to identify a managed tenant and creates a pending tenant + session', 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 = '11111111-1111-1111-1111-111111111111';
|
|
|
|
Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace])
|
|
->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
|
|
|
|
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
|
|
|
|
expect((int) $tenant->workspace_id)->toBe((int) $workspace->getKey());
|
|
expect($tenant->status)->toBe('pending');
|
|
|
|
$this->assertDatabaseHas('managed_tenant_onboarding_sessions', [
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'current_step' => 'identify',
|
|
]);
|
|
|
|
$this->assertDatabaseHas('tenant_memberships', [
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'user_id' => (int) $user->getKey(),
|
|
'role' => 'owner',
|
|
]);
|
|
|
|
expect(
|
|
(int) \App\Models\TenantMembership::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('role', 'owner')
|
|
->count()
|
|
)->toBe(1);
|
|
});
|
|
|
|
it('upgrades the initiating user to owner if they already have a lower tenant role', function (): void {
|
|
$workspace = Workspace::factory()->create();
|
|
$user = User::factory()->create();
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => $workspace->getKey(),
|
|
'user_id' => $user->getKey(),
|
|
'role' => 'owner',
|
|
]);
|
|
|
|
$tenantGuid = '66666666-6666-6666-6666-666666666666';
|
|
|
|
$tenant = Tenant::factory()->create([
|
|
'workspace_id' => $workspace->getKey(),
|
|
'tenant_id' => $tenantGuid,
|
|
'name' => 'Acme',
|
|
'status' => 'pending',
|
|
]);
|
|
|
|
\App\Models\TenantMembership::query()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'user_id' => (int) $user->getKey(),
|
|
'role' => 'readonly',
|
|
'source' => 'manual',
|
|
'created_by_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']);
|
|
|
|
$membership = \App\Models\TenantMembership::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('user_id', (int) $user->getKey())
|
|
->firstOrFail();
|
|
|
|
expect($membership->role)->toBe('owner');
|
|
|
|
expect(\App\Models\TenantMembership::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('user_id', (int) $user->getKey())
|
|
->count())->toBe(1);
|
|
});
|
|
|
|
it('writes audit logs for onboarding start and resume', 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 = '44444444-4444-4444-4444-444444444444';
|
|
|
|
$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']);
|
|
|
|
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
|
|
|
|
$this->assertDatabaseHas('audit_logs', [
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'actor_id' => (int) $user->getKey(),
|
|
'action' => 'managed_tenant_onboarding.start',
|
|
'resource_type' => 'tenant',
|
|
'resource_id' => (string) $tenant->getKey(),
|
|
'status' => 'success',
|
|
]);
|
|
|
|
$this->assertDatabaseHas('audit_logs', [
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'actor_id' => (int) $user->getKey(),
|
|
'action' => 'managed_tenant_onboarding.resume',
|
|
'resource_type' => 'tenant',
|
|
'resource_id' => (string) $tenant->getKey(),
|
|
'status' => 'success',
|
|
]);
|
|
|
|
expect(AuditLog::query()
|
|
->where('workspace_id', (int) $workspace->getKey())
|
|
->where('resource_type', 'tenant')
|
|
->where('resource_id', (string) $tenant->getKey())
|
|
->whereIn('action', ['managed_tenant_onboarding.start', 'managed_tenant_onboarding.resume'])
|
|
->count())->toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it('blocks demoting or removing the last remaining tenant owner', 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 = '55555555-5555-5555-5555-555555555555';
|
|
|
|
Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace])
|
|
->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
|
|
|
|
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
|
|
$membership = \App\Models\TenantMembership::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('user_id', $user->getKey())
|
|
->firstOrFail();
|
|
|
|
expect(fn () => app(TenantMembershipManager::class)->changeRole($tenant, $user, $membership, 'manager'))
|
|
->toThrow(DomainException::class, 'You cannot demote the last remaining owner.');
|
|
|
|
expect(fn () => app(TenantMembershipManager::class)->removeMember($tenant, $user, $membership))
|
|
->toThrow(DomainException::class, 'You cannot remove the last remaining owner.');
|
|
});
|
|
|
|
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('is idempotent when identifying the same managed tenant twice', 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 = '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',
|
|
]);
|
|
});
|