## Summary - add canonical onboarding lifecycle and checkpoint fields plus optimistic locking versioning for managed tenant onboarding drafts - introduce centralized onboarding lifecycle and mutation services and route wizard mutations through version-checked writes - convert Verify Access and Bootstrap into live checkpoint-driven wizard states with conditional polling and updated browser/feature/unit coverage - add Spec Kit artifacts for feature 140, including spec, plan, tasks, research, data model, quickstart, checklist, and logical contract ## Validation - branch was committed and pushed cleanly - focused tests and formatting were updated during implementation work - full validation was not re-run as part of this final git/PR step ## Notes - base branch: `dev` - feature branch: `140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp` - outstanding follow-up items, if any, remain tracked in `specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/tasks.md` Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #169
302 lines
11 KiB
PHP
302 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\OperationRun;
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\Verification\VerificationReportWriter;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
|
|
pest()->browser()->timeout(10_000);
|
|
|
|
it('restores the canonical draft route, derived stage, and transient secret inputs after a refresh', function (): void {
|
|
$workspace = Workspace::factory()->create();
|
|
$tenant = Tenant::factory()->create([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'tenant_id' => '20202020-2020-2020-2020-202020202020',
|
|
'name' => 'Browser Refresh Tenant',
|
|
'status' => Tenant::STATUS_ONBOARDING,
|
|
]);
|
|
$user = User::factory()->create(['name' => 'Browser Owner']);
|
|
|
|
createUserWithTenant(
|
|
tenant: $tenant,
|
|
user: $user,
|
|
role: 'owner',
|
|
workspaceRole: 'owner',
|
|
ensureDefaultMicrosoftProviderConnection: false,
|
|
);
|
|
|
|
$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' => 'Browser platform connection',
|
|
'is_default' => true,
|
|
'status' => 'connected',
|
|
]);
|
|
|
|
$draft = createOnboardingDraft([
|
|
'workspace' => $workspace,
|
|
'tenant' => $tenant,
|
|
'started_by' => $user,
|
|
'updated_by' => $user,
|
|
'current_step' => 'connection',
|
|
'state' => [
|
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
|
'tenant_name' => (string) $tenant->name,
|
|
'environment' => 'prod',
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
],
|
|
]);
|
|
|
|
$this->actingAs($user)->withSession([
|
|
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
|
]);
|
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
|
|
|
$visibleSelectValue = <<<'JS'
|
|
(() => {
|
|
const select = [...document.querySelectorAll('select')].find((element) => {
|
|
const style = window.getComputedStyle(element);
|
|
|
|
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
});
|
|
|
|
return select?.value ?? null;
|
|
})()
|
|
JS;
|
|
|
|
$page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]));
|
|
|
|
$page
|
|
->assertNoJavaScriptErrors()
|
|
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
|
->assertSee('Onboarding draft')
|
|
->assertSee('Verify access')
|
|
->assertSee('Status: Not started')
|
|
->refresh()
|
|
->waitForText('Status: Not started')
|
|
->assertNoJavaScriptErrors()
|
|
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
|
->assertSee('Verify access')
|
|
->assertSee('Status: Not started')
|
|
->click('Provider connection')
|
|
->assertScript($visibleSelectValue, (string) $connection->getKey())
|
|
->click('Create new connection')
|
|
->check('internal:label="Dedicated override"s')
|
|
->fill('[type="password"]', 'browser-only-secret')
|
|
->assertValue('[type="password"]', 'browser-only-secret')
|
|
->refresh()
|
|
->waitForText('Status: Not started')
|
|
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
|
->assertSee('Verify access')
|
|
->click('Provider connection')
|
|
->assertScript($visibleSelectValue, (string) $connection->getKey())
|
|
->click('Create new connection')
|
|
->check('internal:label="Dedicated override"s')
|
|
->assertValue('[type="password"]', '');
|
|
});
|
|
|
|
it('auto-refreshes verification status and blocked assist visibility without a manual refresh', function (): void {
|
|
$workspace = Workspace::factory()->create();
|
|
$tenant = Tenant::factory()->create([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'tenant_id' => '50505050-5050-5050-5050-505050505050',
|
|
'name' => 'Browser Poll Verification Tenant',
|
|
'status' => Tenant::STATUS_ONBOARDING,
|
|
]);
|
|
$user = User::factory()->create(['name' => 'Polling Owner']);
|
|
|
|
createUserWithTenant(
|
|
tenant: $tenant,
|
|
user: $user,
|
|
role: 'owner',
|
|
workspaceRole: 'owner',
|
|
ensureDefaultMicrosoftProviderConnection: false,
|
|
);
|
|
|
|
$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' => 'Polling verification connection',
|
|
'is_default' => true,
|
|
'status' => 'connected',
|
|
]);
|
|
|
|
$run = OperationRun::factory()->create([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'type' => 'provider.connection.check',
|
|
'status' => OperationRunStatus::Running->value,
|
|
'outcome' => OperationRunOutcome::Pending->value,
|
|
'context' => [
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'target_scope' => [
|
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
|
'entra_tenant_name' => (string) $tenant->name,
|
|
],
|
|
],
|
|
]);
|
|
|
|
$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(),
|
|
'verification_operation_run_id' => (int) $run->getKey(),
|
|
],
|
|
]);
|
|
|
|
$this->actingAs($user)->withSession([
|
|
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
|
]);
|
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
|
|
|
$page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]));
|
|
|
|
$page
|
|
->assertNoJavaScriptErrors()
|
|
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
|
->assertSee('Verify access')
|
|
->assertSee('Status: In progress')
|
|
->assertSee('Status updates automatically about every 5 seconds.');
|
|
|
|
$run->forceFill([
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Blocked->value,
|
|
'completed_at' => now(),
|
|
'context' => [
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'target_scope' => [
|
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
|
'entra_tenant_name' => (string) $tenant->name,
|
|
],
|
|
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
|
[
|
|
'key' => 'permissions.admin_consent',
|
|
'title' => 'Required application permissions',
|
|
'status' => 'fail',
|
|
'severity' => 'critical',
|
|
'blocking' => true,
|
|
'reason_code' => 'permission_denied',
|
|
'message' => 'Missing required Graph permissions.',
|
|
'evidence' => [],
|
|
'next_steps' => [],
|
|
],
|
|
]),
|
|
],
|
|
])->save();
|
|
|
|
$page
|
|
->wait(7)
|
|
->assertNoJavaScriptErrors()
|
|
->assertSee('Status: Blocked')
|
|
->assertSee('View required permissions');
|
|
});
|
|
|
|
it('auto-refreshes bootstrap checkpoint summaries without a manual refresh', function (): void {
|
|
$workspace = Workspace::factory()->create();
|
|
$tenant = Tenant::factory()->create([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'tenant_id' => '60606060-6060-6060-6060-606060606060',
|
|
'name' => 'Browser Poll Bootstrap Tenant',
|
|
'status' => Tenant::STATUS_ONBOARDING,
|
|
]);
|
|
$user = User::factory()->create(['name' => 'Bootstrap Poll Owner']);
|
|
|
|
createUserWithTenant(
|
|
tenant: $tenant,
|
|
user: $user,
|
|
role: 'owner',
|
|
workspaceRole: 'owner',
|
|
ensureDefaultMicrosoftProviderConnection: false,
|
|
);
|
|
|
|
$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' => 'Polling bootstrap connection',
|
|
'is_default' => true,
|
|
'status' => 'connected',
|
|
]);
|
|
|
|
$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,
|
|
'completed_at' => now(),
|
|
'context' => [
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'target_scope' => [
|
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
|
'entra_tenant_name' => (string) $tenant->name,
|
|
],
|
|
],
|
|
]);
|
|
|
|
$bootstrapRun = createInventorySyncOperationRun($tenant, [
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'status' => 'running',
|
|
]);
|
|
|
|
$draft = createOnboardingDraft([
|
|
'workspace' => $workspace,
|
|
'tenant' => $tenant,
|
|
'started_by' => $user,
|
|
'updated_by' => $user,
|
|
'current_step' => 'bootstrap',
|
|
'state' => [
|
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
|
'tenant_name' => (string) $tenant->name,
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'verification_operation_run_id' => (int) $verificationRun->getKey(),
|
|
'bootstrap_operation_types' => ['inventory_sync'],
|
|
'bootstrap_operation_runs' => [
|
|
'inventory_sync' => (int) $bootstrapRun->getKey(),
|
|
],
|
|
],
|
|
]);
|
|
|
|
$this->actingAs($user)->withSession([
|
|
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
|
]);
|
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
|
|
|
$page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]));
|
|
|
|
$page
|
|
->assertNoJavaScriptErrors()
|
|
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
|
->assertSee('Bootstrap (optional)')
|
|
->assertSee('Bootstrap is running across 1 operation run(s).');
|
|
|
|
$bootstrapRun->forceFill([
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
'completed_at' => now(),
|
|
])->save();
|
|
|
|
$page
|
|
->wait(7)
|
|
->assertNoJavaScriptErrors()
|
|
->assertSee('Bootstrap completed across 1 operation run(s).');
|
|
});
|