feat: managed tenant onboarding draft identity and resume semantics (#167)
## Summary - add canonical managed-tenant onboarding draft routing with explicit draft identity and landing vs concrete draft behavior - implement draft lifecycle, authorization, attribution, picker UX, resume-stage resolution, and auditable cancel or completion semantics - add focused feature, unit, and browser coverage plus Spec 138 artifacts for the onboarding draft resume flow ## Validation - `vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Audit/OnboardingDraftAuditTest.php tests/Feature/Onboarding/OnboardingDraftAccessTest.php tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php tests/Feature/Onboarding/OnboardingDraftMultiTabTest.php tests/Feature/Onboarding/OnboardingDraftPickerTest.php tests/Feature/Onboarding/OnboardingDraftRoutingTest.php tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php tests/Feature/Onboarding/OnboardingVerificationClustersTest.php tests/Feature/Onboarding/OnboardingVerificationTest.php tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php tests/Unit/Onboarding tests/Unit/VerificationReportSanitizerEvidenceKindsTest.php tests/Browser/OnboardingDraftRefreshTest.php tests/Browser/OnboardingDraftVerificationResumeTest.php` - passed: 69 tests, 251 assertions Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #167
This commit is contained in:
parent
bab01f07a9
commit
98e2b5acd9
1
.gitignore
vendored
1
.gitignore
vendored
@ -32,5 +32,6 @@ Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
/references
|
||||
/tests/Browser/Screenshots
|
||||
*.tmp
|
||||
*.swp
|
||||
|
||||
@ -1,17 +1,14 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
|
||||
- Version change: 1.10.0 → 1.11.0
|
||||
- Version change: 1.11.0 → 1.12.0
|
||||
- Modified principles:
|
||||
- None
|
||||
- Scope & Ownership Clarification (SCOPE-001)
|
||||
- Added sections:
|
||||
- Operator-facing UI Naming Standards (UI-NAMING-001)
|
||||
- None
|
||||
- Removed sections: None
|
||||
- Templates requiring updates:
|
||||
- ✅ .specify/templates/plan-template.md
|
||||
- ✅ .specify/templates/spec-template.md
|
||||
- ✅ .specify/templates/tasks-template.md
|
||||
- N/A: .specify/templates/commands/ (directory not present in this repo)
|
||||
- None
|
||||
- Follow-up TODOs:
|
||||
- None.
|
||||
-->
|
||||
@ -68,6 +65,7 @@ ### Tenant Isolation is Non-negotiable
|
||||
- Workspace-owned tables MUST include workspace_id and MUST NOT include tenant_id.
|
||||
- Exception: OperationRun MAY have tenant_id nullable to support canonical workspace-context monitoring views; however, revealing any tenant-bound runs still MUST enforce entitlement checks to the referenced tenant scope.
|
||||
- Exception: AlertDelivery MAY have tenant_id nullable for workspace-scoped, non-tenant-operational artifacts (e.g., `event_type=alerts.test`). Tenant-bound delivery records still MUST enforce tenant entitlement checks, and tenantless delivery rows MUST NOT contain tenant-specific data.
|
||||
- Exception: `managed_tenant_onboarding_sessions` MAY keep `tenant_id` nullable as a workspace-scoped workflow-coordination record. It begins before tenant identification, may later reference a tenant for authorization continuity and resume semantics, and MUST always enforce workspace entitlement plus tenant entitlement once a tenant reference is attached. This exception is specific to onboarding draft workflow state and does not create a general precedent for workspace-owned domain records.
|
||||
|
||||
### RBAC & UI Enforcement Standards (RBAC-UX)
|
||||
|
||||
|
||||
@ -681,7 +681,7 @@ ## Foundational Context
|
||||
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4.1
|
||||
- php - 8.4.15
|
||||
- filament/filament (FILAMENT) - v5
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
|
||||
@ -521,7 +521,7 @@ ## Foundational Context
|
||||
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4.1
|
||||
- php - 8.4.15
|
||||
- filament/filament (FILAMENT) - v5
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
|
||||
@ -21,6 +21,8 @@
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Onboarding\OnboardingDraftResolver;
|
||||
use App\Services\Onboarding\OnboardingDraftStageResolver;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Providers\ProviderConnectionMutationService;
|
||||
use App\Services\Providers\ProviderConnectionStateProjector;
|
||||
@ -31,6 +33,7 @@
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Onboarding\OnboardingDraftStage;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
@ -65,7 +68,9 @@
|
||||
use Filament\Support\Enums\FontWeight;
|
||||
use Filament\Support\Enums\Width;
|
||||
use Filament\Support\Exceptions\Halt;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use InvalidArgumentException;
|
||||
@ -74,6 +79,8 @@
|
||||
|
||||
class ManagedTenantOnboardingWizard extends Page
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
protected static string $layout = 'filament-panels::components.layout.simple';
|
||||
|
||||
protected Width|string|null $maxContentWidth = Width::ScreenExtraLarge;
|
||||
@ -106,6 +113,10 @@ protected function getLayoutData(): array
|
||||
|
||||
public ?int $selectedProviderConnectionId = null;
|
||||
|
||||
public bool $showDraftPicker = false;
|
||||
|
||||
public bool $showStartState = false;
|
||||
|
||||
/**
|
||||
* Filament schema state.
|
||||
*
|
||||
@ -123,10 +134,30 @@ protected function getLayoutData(): array
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
$actions = [];
|
||||
|
||||
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
$actions[] = Action::make('back_to_onboarding_landing')
|
||||
->label('All onboarding drafts')
|
||||
->color('gray')
|
||||
->url(route('admin.onboarding'));
|
||||
}
|
||||
|
||||
if ($this->onboardingSession instanceof TenantOnboardingSession && $this->onboardingSession->isResumable()) {
|
||||
$actions[] = Action::make('cancel_onboarding_draft')
|
||||
->label('Cancel draft')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Cancel onboarding draft')
|
||||
->modalDescription('This draft will become non-resumable. Confirm only if you intend to stop this onboarding flow.')
|
||||
->visible(fn (): bool => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CANCEL))
|
||||
->action(fn () => $this->cancelOnboardingDraft());
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
public function mount(TenantOnboardingSession|int|string|null $onboardingDraft = null): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
@ -137,6 +168,10 @@ public function mount(): void
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if ($workspaceId === null) {
|
||||
if ($onboardingDraft !== null) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->redirect('/admin/choose-workspace');
|
||||
|
||||
return;
|
||||
@ -154,16 +189,37 @@ public function mount(): void
|
||||
|
||||
$this->workspace = $workspace;
|
||||
|
||||
$this->resumeLatestOnboardingSessionIfUnambiguous();
|
||||
$this->authorize('viewAny', TenantOnboardingSession::class);
|
||||
|
||||
if ($onboardingDraft !== null) {
|
||||
$this->loadOnboardingDraft($user, $onboardingDraft);
|
||||
$this->initializeWizardData();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->resolveLandingState($user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->showStartState = true;
|
||||
$this->initializeWizardData();
|
||||
}
|
||||
|
||||
public function content(Schema $schema): Schema
|
||||
{
|
||||
if ($this->showDraftPicker) {
|
||||
return $schema->schema($this->draftPickerSchema());
|
||||
}
|
||||
|
||||
if ($this->showsNonResumableSummary()) {
|
||||
return $schema->schema($this->nonResumableSummarySchema());
|
||||
}
|
||||
|
||||
return $schema
|
||||
->statePath('data')
|
||||
->schema([
|
||||
...$this->resumeContextSchema(),
|
||||
Wizard::make([
|
||||
Step::make('Identify managed tenant')
|
||||
->description('Create or resume a managed tenant in this workspace.')
|
||||
@ -547,50 +603,365 @@ public function content(Schema $schema): Schema
|
||||
]);
|
||||
}
|
||||
|
||||
private function resumeLatestOnboardingSessionIfUnambiguous(): void
|
||||
private function resolveLandingState(User $user): bool
|
||||
{
|
||||
$sessionCount = TenantOnboardingSession::query()
|
||||
->where('workspace_id', (int) $this->workspace->getKey())
|
||||
->whereNull('completed_at')
|
||||
->count();
|
||||
$drafts = $this->availableDraftsFor($user);
|
||||
|
||||
if ($sessionCount !== 1) {
|
||||
return;
|
||||
if ($drafts->count() === 1) {
|
||||
$draft = $drafts->first();
|
||||
|
||||
if ($draft instanceof TenantOnboardingSession) {
|
||||
$this->redirect(route('admin.onboarding.draft', ['onboardingDraft' => $draft]));
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$session = TenantOnboardingSession::query()
|
||||
->where('workspace_id', (int) $this->workspace->getKey())
|
||||
->whereNull('completed_at')
|
||||
->orderByDesc('updated_at')
|
||||
->first();
|
||||
|
||||
if (! $session instanceof TenantOnboardingSession) {
|
||||
return;
|
||||
if ($drafts->count() > 1) {
|
||||
$this->showDraftPicker = true;
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()
|
||||
->where('workspace_id', (int) $this->workspace->getKey())
|
||||
->whereKey((int) $session->tenant_id)
|
||||
->first();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
private function loadOnboardingDraft(User $user, TenantOnboardingSession|int|string $onboardingDraft): void
|
||||
{
|
||||
$draft = app(OnboardingDraftResolver::class)->resolve($onboardingDraft, $user, $this->workspace);
|
||||
|
||||
$this->showDraftPicker = false;
|
||||
$this->showStartState = false;
|
||||
$this->onboardingSession = $draft;
|
||||
|
||||
$tenant = $draft->tenant;
|
||||
|
||||
if ($tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $this->workspace->getKey()) {
|
||||
$this->managedTenant = $tenant;
|
||||
}
|
||||
|
||||
$this->managedTenant = $tenant;
|
||||
$this->onboardingSession = $session;
|
||||
|
||||
$providerConnectionId = $session->state['provider_connection_id'] ?? null;
|
||||
$providerConnectionId = $draft->state['provider_connection_id'] ?? null;
|
||||
$this->selectedProviderConnectionId = is_int($providerConnectionId)
|
||||
? $providerConnectionId
|
||||
: $this->resolveDefaultProviderConnectionId($tenant);
|
||||
: ($tenant instanceof Tenant ? $this->resolveDefaultProviderConnectionId($tenant) : null);
|
||||
|
||||
$bootstrapTypes = $session->state['bootstrap_operation_types'] ?? [];
|
||||
$bootstrapTypes = $draft->state['bootstrap_operation_types'] ?? [];
|
||||
$this->selectedBootstrapOperationTypes = is_array($bootstrapTypes)
|
||||
? array_values(array_filter($bootstrapTypes, static fn ($v): bool => is_string($v) && $v !== ''))
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, TenantOnboardingSession>
|
||||
*/
|
||||
private function availableDraftsFor(User $user): Collection
|
||||
{
|
||||
return app(OnboardingDraftResolver::class)->resumableDraftsFor($user, $this->workspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, \Filament\Schemas\Components\Component>
|
||||
*/
|
||||
private function draftPickerSchema(): array
|
||||
{
|
||||
$drafts = $this->currentUser() instanceof User
|
||||
? $this->availableDraftsFor($this->currentUser())
|
||||
: collect();
|
||||
|
||||
$components = [
|
||||
Callout::make('Multiple onboarding drafts are available.')
|
||||
->description('Select a draft to resume or start a new onboarding flow explicitly.')
|
||||
->warning(),
|
||||
SchemaActions::make([
|
||||
Action::make('start_new_onboarding_draft')
|
||||
->label('Start new onboarding')
|
||||
->color('primary')
|
||||
->action(fn () => $this->startNewOnboardingDraft()),
|
||||
])->key('draft_picker_start_actions'),
|
||||
];
|
||||
|
||||
foreach ($drafts as $draft) {
|
||||
$components[] = Section::make($this->draftTitle($draft))
|
||||
->description($this->draftDescription($draft))
|
||||
->compact()
|
||||
->columns(2)
|
||||
->schema([
|
||||
Text::make('Current stage')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => $this->draftStageLabel($draft))
|
||||
->badge()
|
||||
->color(fn (): string => $this->draftStageColor($draft)),
|
||||
Text::make('Status')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => $draft->status()->label())
|
||||
->badge()
|
||||
->color(fn (): string => $this->draftStatusColor($draft)),
|
||||
Text::make('Started by')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => $draft->startedByUser?->name ?? 'Unknown'),
|
||||
Text::make('Last updated by')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => $draft->updatedByUser?->name ?? 'Unknown'),
|
||||
Text::make('Last updated')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => $draft->updated_at?->diffForHumans() ?? '—'),
|
||||
Text::make('Draft age')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => $draft->created_at?->diffForHumans() ?? '—'),
|
||||
SchemaActions::make([
|
||||
Action::make('resume_draft_'.$draft->getKey())
|
||||
->label('Resume onboarding draft')
|
||||
->action(fn () => $this->resumeOnboardingDraft((int) $draft->getKey(), true)),
|
||||
Action::make('view_draft_'.$draft->getKey())
|
||||
->label('View summary')
|
||||
->color('gray')
|
||||
->action(fn () => $this->resumeOnboardingDraft((int) $draft->getKey(), false)),
|
||||
])->key('draft_picker_actions_'.$draft->getKey()),
|
||||
]);
|
||||
}
|
||||
|
||||
return $components;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, \Filament\Schemas\Components\Component>
|
||||
*/
|
||||
private function resumeContextSchema(): array
|
||||
{
|
||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
Section::make('Onboarding draft')
|
||||
->compact()
|
||||
->columns(2)
|
||||
->schema([
|
||||
Text::make('Tenant')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => $this->draftTitle($this->onboardingSession))
|
||||
->weight(FontWeight::SemiBold),
|
||||
Text::make('Current stage')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => $this->draftStageLabel($this->onboardingSession))
|
||||
->badge()
|
||||
->color(fn (): string => $this->draftStageColor($this->onboardingSession)),
|
||||
Text::make('Started by')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => $this->onboardingSession?->startedByUser?->name ?? 'Unknown'),
|
||||
Text::make('Last updated by')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => $this->onboardingSession?->updatedByUser?->name ?? 'Unknown'),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, \Filament\Schemas\Components\Component>
|
||||
*/
|
||||
private function nonResumableSummarySchema(): array
|
||||
{
|
||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$statusLabel = $this->onboardingSession->status()->label();
|
||||
|
||||
return [
|
||||
Callout::make("This onboarding draft is {$statusLabel}.")
|
||||
->description('Completed and cancelled drafts remain viewable, but they cannot return to editable wizard mode.')
|
||||
->warning(),
|
||||
...$this->resumeContextSchema(),
|
||||
Section::make('Draft summary')
|
||||
->compact()
|
||||
->columns(2)
|
||||
->schema([
|
||||
Text::make('Status')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => $statusLabel)
|
||||
->badge()
|
||||
->color(fn (): string => $this->draftStatusColor($this->onboardingSession)),
|
||||
Text::make('Primary domain')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => (string) (($this->onboardingSession?->state['primary_domain'] ?? null) ?: '—')),
|
||||
Text::make('Environment')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => (string) (($this->onboardingSession?->state['environment'] ?? null) ?: '—')),
|
||||
Text::make('Notes')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => (string) (($this->onboardingSession?->state['notes'] ?? null) ?: '—')),
|
||||
]),
|
||||
SchemaActions::make([
|
||||
Action::make('return_to_onboarding_landing')
|
||||
->label('Return to onboarding')
|
||||
->color('gray')
|
||||
->url(route('admin.onboarding')),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
private function startNewOnboardingDraft(): void
|
||||
{
|
||||
$this->showDraftPicker = false;
|
||||
$this->showStartState = true;
|
||||
$this->managedTenant = null;
|
||||
$this->onboardingSession = null;
|
||||
$this->selectedProviderConnectionId = null;
|
||||
$this->selectedBootstrapOperationTypes = [];
|
||||
$this->data = [];
|
||||
|
||||
$this->initializeWizardData();
|
||||
}
|
||||
|
||||
private function resumeOnboardingDraft(int $draftId, bool $logSelection): void
|
||||
{
|
||||
$user = $this->currentUser();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$draft = app(OnboardingDraftResolver::class)->resolve($draftId, $user, $this->workspace);
|
||||
|
||||
if ($logSelection) {
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $this->workspace,
|
||||
action: AuditActionId::ManagedTenantOnboardingDraftSelected->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'workspace_id' => (int) $this->workspace->getKey(),
|
||||
'onboarding_session_id' => (int) $draft->getKey(),
|
||||
'tenant_db_id' => $draft->tenant_id !== null ? (int) $draft->tenant_id : null,
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
status: 'success',
|
||||
resourceType: 'managed_tenant_onboarding_session',
|
||||
resourceId: (string) $draft->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
$this->redirect(route('admin.onboarding.draft', ['onboardingDraft' => $draft]));
|
||||
}
|
||||
|
||||
private function cancelOnboardingDraft(): void
|
||||
{
|
||||
$user = $this->currentUser();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->authorize('cancel', $this->onboardingSession);
|
||||
|
||||
if (! $this->onboardingSession->isResumable()) {
|
||||
Notification::make()
|
||||
->title('Draft is not resumable')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->onboardingSession->forceFill([
|
||||
'current_step' => 'cancelled',
|
||||
'cancelled_at' => now(),
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
])->save();
|
||||
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $this->workspace,
|
||||
action: AuditActionId::ManagedTenantOnboardingCancelled->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'workspace_id' => (int) $this->workspace->getKey(),
|
||||
'tenant_db_id' => $this->onboardingSession->tenant_id !== null ? (int) $this->onboardingSession->tenant_id : null,
|
||||
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
status: 'success',
|
||||
resourceType: 'managed_tenant_onboarding_session',
|
||||
resourceId: (string) $this->onboardingSession->getKey(),
|
||||
);
|
||||
|
||||
$this->onboardingSession->refresh();
|
||||
|
||||
Notification::make()
|
||||
->title('Onboarding draft cancelled')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
private function showsNonResumableSummary(): bool
|
||||
{
|
||||
return $this->onboardingSession instanceof TenantOnboardingSession
|
||||
&& ! $this->onboardingSession->isResumable();
|
||||
}
|
||||
|
||||
private function draftTitle(TenantOnboardingSession $draft): string
|
||||
{
|
||||
$state = is_array($draft->state) ? $draft->state : [];
|
||||
$tenantName = $state['tenant_name'] ?? $draft->tenant?->name ?? null;
|
||||
$tenantName = is_string($tenantName) && trim($tenantName) !== '' ? trim($tenantName) : 'Unidentified tenant';
|
||||
$entraTenantId = $state['entra_tenant_id'] ?? $draft->entra_tenant_id;
|
||||
|
||||
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
|
||||
return sprintf('%s (%s)', $tenantName, trim($entraTenantId));
|
||||
}
|
||||
|
||||
return $tenantName;
|
||||
}
|
||||
|
||||
private function draftDescription(TenantOnboardingSession $draft): string
|
||||
{
|
||||
$state = is_array($draft->state) ? $draft->state : [];
|
||||
$environment = $state['environment'] ?? null;
|
||||
$primaryDomain = $state['primary_domain'] ?? null;
|
||||
|
||||
return collect([
|
||||
is_string($environment) && $environment !== '' ? ucfirst($environment) : null,
|
||||
is_string($primaryDomain) && $primaryDomain !== '' ? $primaryDomain : null,
|
||||
])->filter()->implode(' · ');
|
||||
}
|
||||
|
||||
private function draftStageLabel(TenantOnboardingSession $draft): string
|
||||
{
|
||||
return app(OnboardingDraftStageResolver::class)->resolve($draft)->label();
|
||||
}
|
||||
|
||||
private function draftStageColor(TenantOnboardingSession $draft): string
|
||||
{
|
||||
return match (app(OnboardingDraftStageResolver::class)->resolve($draft)) {
|
||||
OnboardingDraftStage::Identify => 'gray',
|
||||
OnboardingDraftStage::ConnectProvider => 'info',
|
||||
OnboardingDraftStage::VerifyAccess => 'warning',
|
||||
OnboardingDraftStage::Bootstrap => 'info',
|
||||
OnboardingDraftStage::Review => 'success',
|
||||
OnboardingDraftStage::Completed => 'success',
|
||||
OnboardingDraftStage::Cancelled => 'danger',
|
||||
};
|
||||
}
|
||||
|
||||
private function draftStatusColor(TenantOnboardingSession $draft): string
|
||||
{
|
||||
return match ($draft->status()->value) {
|
||||
'draft' => 'info',
|
||||
'completed' => 'success',
|
||||
'cancelled' => 'danger',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
private function currentUser(): ?User
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return $user instanceof User ? $user : null;
|
||||
}
|
||||
|
||||
private function initializeWizardData(): void
|
||||
{
|
||||
// Ensure all entangled schema state paths exist at render time.
|
||||
@ -673,19 +1044,9 @@ private function initializeWizardData(): void
|
||||
|
||||
private function computeWizardStartStep(): int
|
||||
{
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (! $this->resolveSelectedProviderConnection($this->managedTenant)) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (! $this->verificationCanProceed()) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
return 4;
|
||||
return app(OnboardingDraftStageResolver::class)
|
||||
->resolve($this->onboardingSession)
|
||||
->wizardStep();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1144,6 +1505,23 @@ private function touchOnboardingSessionStep(string $step): void
|
||||
'current_step' => $step,
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
])->save();
|
||||
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $this->workspace,
|
||||
action: AuditActionId::ManagedTenantOnboardingDraftUpdated->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'workspace_id' => (int) $this->workspace->getKey(),
|
||||
'tenant_db_id' => $this->onboardingSession->tenant_id !== null ? (int) $this->onboardingSession->tenant_id : null,
|
||||
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
|
||||
'current_step' => $step,
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
status: 'success',
|
||||
resourceType: 'managed_tenant_onboarding_session',
|
||||
resourceId: (string) $this->onboardingSession->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
private function authorizeWorkspaceMutation(User $user, string $capability): void
|
||||
@ -1155,6 +1533,19 @@ private function authorizeWorkspaceMutation(User $user, string $capability): voi
|
||||
}
|
||||
}
|
||||
|
||||
private function authorizeEditableDraft(User $user): void
|
||||
{
|
||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->authorize('update', $this->onboardingSession);
|
||||
|
||||
if (! $this->onboardingSession->isResumable()) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
private function authorizeWorkspaceMember(User $user): void
|
||||
{
|
||||
if (! app(WorkspaceContext::class)->isMember($user, $this->workspace)) {
|
||||
@ -1203,9 +1594,13 @@ public function identifyManagedTenant(array $data): void
|
||||
$primaryDomain = $primaryDomain !== '' ? $primaryDomain : null;
|
||||
$notes = $notes !== '' ? $notes : null;
|
||||
|
||||
DB::transaction(function () use ($user, $entraTenantId, $tenantName, $environment, $primaryDomain, $notes): void {
|
||||
$notificationTitle = 'Onboarding draft ready';
|
||||
$notificationBody = null;
|
||||
|
||||
DB::transaction(function () use ($user, $entraTenantId, $tenantName, $environment, $primaryDomain, $notes, &$notificationTitle, &$notificationBody): void {
|
||||
$auditLogger = app(WorkspaceAuditLogger::class);
|
||||
$membershipManager = app(TenantMembershipManager::class);
|
||||
$currentDraftId = $this->onboardingSession?->getKey();
|
||||
|
||||
$existingTenant = Tenant::query()
|
||||
->withTrashed()
|
||||
@ -1293,7 +1688,7 @@ public function identifyManagedTenant(array $data): void
|
||||
$session = TenantOnboardingSession::query()
|
||||
->where('workspace_id', (int) $this->workspace->getKey())
|
||||
->where('entra_tenant_id', $entraTenantId)
|
||||
->whereNull('completed_at')
|
||||
->resumable()
|
||||
->first();
|
||||
|
||||
$sessionWasCreated = false;
|
||||
@ -1329,6 +1724,16 @@ public function identifyManagedTenant(array $data): void
|
||||
$session->save();
|
||||
}
|
||||
|
||||
$didResumeExistingDraft = ! $sessionWasCreated
|
||||
&& ($currentDraftId === null || (int) $currentDraftId !== (int) $session->getKey());
|
||||
|
||||
$notificationTitle = $didResumeExistingDraft
|
||||
? 'Existing onboarding draft resumed'
|
||||
: 'Onboarding draft ready';
|
||||
$notificationBody = $didResumeExistingDraft
|
||||
? 'A resumable draft already exists for this tenant. TenantAtlas reopened it instead of creating a duplicate.'
|
||||
: null;
|
||||
|
||||
$auditLogger->log(
|
||||
workspace: $this->workspace,
|
||||
action: ($sessionWasCreated
|
||||
@ -1355,10 +1760,19 @@ public function identifyManagedTenant(array $data): void
|
||||
$this->onboardingSession = $session;
|
||||
});
|
||||
|
||||
Notification::make()
|
||||
->title('Managed tenant identified')
|
||||
->success()
|
||||
->send();
|
||||
$notification = Notification::make()
|
||||
->title($notificationTitle)
|
||||
->success();
|
||||
|
||||
if (is_string($notificationBody) && $notificationBody !== '') {
|
||||
$notification->body($notificationBody);
|
||||
}
|
||||
|
||||
$notification->send();
|
||||
|
||||
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
$this->redirect(route('admin.onboarding.draft', ['onboardingDraft' => $this->onboardingSession]));
|
||||
}
|
||||
}
|
||||
|
||||
public function selectProviderConnection(int $providerConnectionId): void
|
||||
@ -1370,6 +1784,7 @@ public function selectProviderConnection(int $providerConnectionId): void
|
||||
}
|
||||
|
||||
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW);
|
||||
$this->authorizeEditableDraft($user);
|
||||
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
abort(404);
|
||||
@ -1402,6 +1817,23 @@ public function selectProviderConnection(int $providerConnectionId): void
|
||||
$this->onboardingSession->save();
|
||||
}
|
||||
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $this->workspace,
|
||||
action: AuditActionId::ManagedTenantOnboardingProviderConnectionChanged->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'workspace_id' => (int) $this->workspace->getKey(),
|
||||
'tenant_db_id' => (int) $this->managedTenant->getKey(),
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'onboarding_session_id' => $this->onboardingSession?->getKey(),
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
status: 'success',
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $connection->getKey(),
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Provider connection selected')
|
||||
->success()
|
||||
@ -1420,6 +1852,7 @@ public function createProviderConnection(array $data): void
|
||||
}
|
||||
|
||||
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE);
|
||||
$this->authorizeEditableDraft($user);
|
||||
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
abort(404);
|
||||
@ -1593,6 +2026,23 @@ public function createProviderConnection(array $data): void
|
||||
$this->onboardingSession->save();
|
||||
}
|
||||
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $this->workspace,
|
||||
action: AuditActionId::ManagedTenantOnboardingProviderConnectionChanged->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'workspace_id' => (int) $this->workspace->getKey(),
|
||||
'tenant_db_id' => (int) $this->managedTenant->getKey(),
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'onboarding_session_id' => $this->onboardingSession?->getKey(),
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
status: 'success',
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $connection->getKey(),
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Provider connection created')
|
||||
->success()
|
||||
@ -1620,6 +2070,7 @@ public function startVerification(): void
|
||||
}
|
||||
|
||||
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START);
|
||||
$this->authorizeEditableDraft($user);
|
||||
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
Notification::make()
|
||||
@ -1705,6 +2156,25 @@ public function startVerification(): void
|
||||
resourceId: (string) $result->run->getKey(),
|
||||
);
|
||||
|
||||
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $this->workspace,
|
||||
action: AuditActionId::ManagedTenantOnboardingVerificationPersisted->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'workspace_id' => (int) $this->workspace->getKey(),
|
||||
'tenant_db_id' => (int) $tenant->getKey(),
|
||||
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
|
||||
'operation_run_id' => (int) $result->run->getKey(),
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
status: 'success',
|
||||
resourceType: 'managed_tenant_onboarding_session',
|
||||
resourceId: (string) $this->onboardingSession->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
if ($result->status === 'scope_busy') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
@ -1816,6 +2286,7 @@ public function startBootstrap(array $operationTypes): void
|
||||
}
|
||||
|
||||
$this->authorizeWorkspaceMember($user);
|
||||
$this->authorizeEditableDraft($user);
|
||||
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
abort(404);
|
||||
@ -1982,6 +2453,24 @@ public function startBootstrap(array $operationTypes): void
|
||||
$this->onboardingSession->current_step = 'bootstrap';
|
||||
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
|
||||
$this->onboardingSession->save();
|
||||
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $this->workspace,
|
||||
action: AuditActionId::ManagedTenantOnboardingBootstrapStarted->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'workspace_id' => (int) $this->workspace->getKey(),
|
||||
'tenant_db_id' => (int) $tenant->getKey(),
|
||||
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
|
||||
'operation_types' => $types,
|
||||
'operation_run_ids' => $bootstrapRuns,
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
status: 'success',
|
||||
resourceType: 'managed_tenant_onboarding_session',
|
||||
resourceId: (string) $this->onboardingSession->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
@ -2275,6 +2764,7 @@ public function completeOnboarding(): void
|
||||
}
|
||||
|
||||
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE);
|
||||
$this->authorizeEditableDraft($user);
|
||||
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
abort(404);
|
||||
@ -2343,11 +2833,32 @@ public function completeOnboarding(): void
|
||||
|
||||
$this->onboardingSession->forceFill([
|
||||
'completed_at' => now(),
|
||||
'cancelled_at' => null,
|
||||
'current_step' => 'complete',
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
])->save();
|
||||
});
|
||||
|
||||
if ($overrideBlocked) {
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $this->workspace,
|
||||
action: AuditActionId::ManagedTenantOnboardingActivationOverrideUsed->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'workspace_id' => (int) $this->workspace->getKey(),
|
||||
'tenant_db_id' => (int) $tenant->getKey(),
|
||||
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
|
||||
'verification_operation_run_id' => $run instanceof OperationRun ? (int) $run->getKey() : null,
|
||||
'override_reason' => $overrideReason,
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
status: 'override',
|
||||
resourceType: 'managed_tenant_onboarding_session',
|
||||
resourceId: (string) $this->onboardingSession->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $this->workspace,
|
||||
action: AuditActionId::ManagedTenantOnboardingActivation->value,
|
||||
|
||||
@ -173,6 +173,10 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preg_match('#^/admin/onboarding/[^/]+$#', $path) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($path === '/livewire/update') {
|
||||
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
|
||||
$refererPath = '/'.ltrim((string) $refererPath, '/');
|
||||
@ -180,6 +184,10 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool
|
||||
if (preg_match('#^/admin/operations/[^/]+$#', $refererPath) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preg_match('#^/admin/onboarding(?:/[^/]+)?$#', $refererPath) === 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return preg_match('#^/admin/operations/[^/]+$#', $path) === 1;
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Onboarding\OnboardingDraftStatus;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@ -38,6 +42,7 @@ class TenantOnboardingSession extends Model
|
||||
protected $casts = [
|
||||
'state' => 'array',
|
||||
'completed_at' => 'datetime',
|
||||
'cancelled_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
@ -89,4 +94,43 @@ public function updatedByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeResumable(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at');
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->completed_at !== null;
|
||||
}
|
||||
|
||||
public function isCancelled(): bool
|
||||
{
|
||||
return $this->cancelled_at !== null;
|
||||
}
|
||||
|
||||
public function status(): OnboardingDraftStatus
|
||||
{
|
||||
if ($this->isCancelled()) {
|
||||
return OnboardingDraftStatus::Cancelled;
|
||||
}
|
||||
|
||||
if ($this->isCompleted()) {
|
||||
return OnboardingDraftStatus::Completed;
|
||||
}
|
||||
|
||||
return OnboardingDraftStatus::Draft;
|
||||
}
|
||||
|
||||
public function isResumable(): bool
|
||||
{
|
||||
return $this->status()->isResumable();
|
||||
}
|
||||
}
|
||||
|
||||
118
app/Policies/TenantOnboardingSessionPolicy.php
Normal file
118
app/Policies/TenantOnboardingSessionPolicy.php
Normal file
@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class TenantOnboardingSessionPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool|Response
|
||||
{
|
||||
$workspace = $this->currentWorkspace($user);
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return $this->authorizeForWorkspace($user, $workspace, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD);
|
||||
}
|
||||
|
||||
public function view(User $user, TenantOnboardingSession $tenantOnboardingSession): bool|Response
|
||||
{
|
||||
return $this->authorizeForDraft(
|
||||
user: $user,
|
||||
tenantOnboardingSession: $tenantOnboardingSession,
|
||||
capability: Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD,
|
||||
);
|
||||
}
|
||||
|
||||
public function update(User $user, TenantOnboardingSession $tenantOnboardingSession): bool|Response
|
||||
{
|
||||
return $this->authorizeForDraft(
|
||||
user: $user,
|
||||
tenantOnboardingSession: $tenantOnboardingSession,
|
||||
capability: Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD,
|
||||
);
|
||||
}
|
||||
|
||||
public function cancel(User $user, TenantOnboardingSession $tenantOnboardingSession): bool|Response
|
||||
{
|
||||
return $this->authorizeForDraft(
|
||||
user: $user,
|
||||
tenantOnboardingSession: $tenantOnboardingSession,
|
||||
capability: Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CANCEL,
|
||||
);
|
||||
}
|
||||
|
||||
private function currentWorkspace(User $user): ?Workspace
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $workspace)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $workspace;
|
||||
}
|
||||
|
||||
private function authorizeForDraft(
|
||||
User $user,
|
||||
TenantOnboardingSession $tenantOnboardingSession,
|
||||
string $capability,
|
||||
): bool|Response {
|
||||
$workspace = $this->currentWorkspace($user);
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if ((int) $tenantOnboardingSession->workspace_id !== (int) $workspace->getKey()) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
$tenant = $tenantOnboardingSession->tenant;
|
||||
|
||||
if ($tenant instanceof Tenant && ! $user->canAccessTenant($tenant)) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return $this->authorizeForWorkspace($user, $workspace, $capability);
|
||||
}
|
||||
|
||||
private function authorizeForWorkspace(User $user, Workspace $workspace, string $capability): bool|Response
|
||||
{
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $workspace)) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->allows($capability, $workspace)
|
||||
? Response::allow()
|
||||
: Response::deny();
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSetting;
|
||||
@ -15,6 +16,7 @@
|
||||
use App\Policies\AlertDestinationPolicy;
|
||||
use App\Policies\AlertRulePolicy;
|
||||
use App\Policies\ProviderConnectionPolicy;
|
||||
use App\Policies\TenantOnboardingSessionPolicy;
|
||||
use App\Policies\WorkspaceSettingPolicy;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
@ -27,6 +29,7 @@ class AuthServiceProvider extends ServiceProvider
|
||||
{
|
||||
protected $policies = [
|
||||
ProviderConnection::class => ProviderConnectionPolicy::class,
|
||||
TenantOnboardingSession::class => TenantOnboardingSessionPolicy::class,
|
||||
WorkspaceSetting::class => WorkspaceSettingPolicy::class,
|
||||
AlertDestination::class => AlertDestinationPolicy::class,
|
||||
AlertDelivery::class => AlertDeliveryPolicy::class,
|
||||
|
||||
@ -147,7 +147,7 @@ public function panel(Panel $panel): Panel
|
||||
)
|
||||
->renderHook(
|
||||
PanelsRenderHook::BODY_END,
|
||||
fn (): string => request()->routeIs('admin.workspace.managed-tenants.index', 'admin.onboarding', 'filament.admin.pages.choose-tenant')
|
||||
fn (): string => request()->routeIs('admin.workspace.managed-tenants.index', 'admin.onboarding', 'admin.onboarding.draft', 'filament.admin.pages.choose-tenant')
|
||||
? ''
|
||||
: ((bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)
|
||||
? view('livewire.bulk-operation-progress-wrapper')->render()
|
||||
|
||||
@ -25,6 +25,7 @@ class WorkspaceRoleCapabilityMap
|
||||
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_IDENTIFY,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CANCEL,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED,
|
||||
@ -48,6 +49,7 @@ class WorkspaceRoleCapabilityMap
|
||||
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_IDENTIFY,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CANCEL,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START,
|
||||
|
||||
67
app/Services/Onboarding/OnboardingDraftResolver.php
Normal file
67
app/Services/Onboarding/OnboardingDraftResolver.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Onboarding;
|
||||
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class OnboardingDraftResolver
|
||||
{
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
* @throws NotFoundHttpException
|
||||
*/
|
||||
public function resolve(TenantOnboardingSession|int|string $draft, User $user, Workspace $workspace): TenantOnboardingSession
|
||||
{
|
||||
$draftId = $draft instanceof TenantOnboardingSession
|
||||
? (int) $draft->getKey()
|
||||
: (int) $draft;
|
||||
|
||||
$resolvedDraft = TenantOnboardingSession::query()
|
||||
->with(['tenant', 'startedByUser', 'updatedByUser'])
|
||||
->whereKey($draftId)
|
||||
->first();
|
||||
|
||||
if (! $resolvedDraft instanceof TenantOnboardingSession) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if ((int) $resolvedDraft->workspace_id !== (int) $workspace->getKey()) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
Gate::forUser($user)->authorize('view', $resolvedDraft);
|
||||
|
||||
return $resolvedDraft;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, TenantOnboardingSession>
|
||||
*/
|
||||
public function resumableDraftsFor(User $user, Workspace $workspace): Collection
|
||||
{
|
||||
$drafts = TenantOnboardingSession::query()
|
||||
->with(['tenant', 'startedByUser', 'updatedByUser'])
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->resumable()
|
||||
->orderByDesc('updated_at')
|
||||
->get();
|
||||
|
||||
return $drafts->filter(function (TenantOnboardingSession $draft) use ($user): bool {
|
||||
try {
|
||||
Gate::forUser($user)->authorize('view', $draft);
|
||||
} catch (AuthorizationException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})->values();
|
||||
}
|
||||
}
|
||||
141
app/Services/Onboarding/OnboardingDraftStageResolver.php
Normal file
141
app/Services/Onboarding/OnboardingDraftStageResolver.php
Normal file
@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Onboarding;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Support\Onboarding\OnboardingDraftStage;
|
||||
use App\Support\OperationRunStatus;
|
||||
|
||||
class OnboardingDraftStageResolver
|
||||
{
|
||||
public function resolve(?TenantOnboardingSession $draft): OnboardingDraftStage
|
||||
{
|
||||
if (! $draft instanceof TenantOnboardingSession) {
|
||||
return OnboardingDraftStage::Identify;
|
||||
}
|
||||
|
||||
if ($draft->isCancelled()) {
|
||||
return OnboardingDraftStage::Cancelled;
|
||||
}
|
||||
|
||||
if ($draft->isCompleted()) {
|
||||
return OnboardingDraftStage::Completed;
|
||||
}
|
||||
|
||||
$state = is_array($draft->state) ? $draft->state : [];
|
||||
|
||||
if (! $this->hasTenantIdentity($draft, $state)) {
|
||||
return OnboardingDraftStage::Identify;
|
||||
}
|
||||
|
||||
if (! $this->hasProviderConnection($state)) {
|
||||
return OnboardingDraftStage::ConnectProvider;
|
||||
}
|
||||
|
||||
if (! $this->verificationCanProceed($draft, $state)) {
|
||||
return OnboardingDraftStage::VerifyAccess;
|
||||
}
|
||||
|
||||
if ($this->reviewStateWasConfirmed($draft, $state)) {
|
||||
return OnboardingDraftStage::Review;
|
||||
}
|
||||
|
||||
return OnboardingDraftStage::Bootstrap;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $state
|
||||
*/
|
||||
private function hasTenantIdentity(TenantOnboardingSession $draft, array $state): bool
|
||||
{
|
||||
if ($draft->tenant_id !== null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$entraTenantId = $state['entra_tenant_id'] ?? $draft->entra_tenant_id;
|
||||
|
||||
return is_string($entraTenantId) && trim($entraTenantId) !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $state
|
||||
*/
|
||||
private function hasProviderConnection(array $state): bool
|
||||
{
|
||||
$providerConnectionId = $state['provider_connection_id'] ?? null;
|
||||
|
||||
return is_int($providerConnectionId)
|
||||
|| (is_numeric($providerConnectionId) && (int) $providerConnectionId > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $state
|
||||
*/
|
||||
private function verificationCanProceed(TenantOnboardingSession $draft, array $state): bool
|
||||
{
|
||||
$run = $this->verificationRun($draft, $state);
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($run->status !== OperationRunStatus::Completed->value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$selectedProviderConnectionId = $state['provider_connection_id'] ?? null;
|
||||
$selectedProviderConnectionId = is_int($selectedProviderConnectionId)
|
||||
? $selectedProviderConnectionId
|
||||
: (is_numeric($selectedProviderConnectionId) ? (int) $selectedProviderConnectionId : null);
|
||||
|
||||
$runContext = is_array($run->context) ? $run->context : [];
|
||||
$runProviderConnectionId = $runContext['provider_connection_id'] ?? null;
|
||||
$runProviderConnectionId = is_int($runProviderConnectionId)
|
||||
? $runProviderConnectionId
|
||||
: (is_numeric($runProviderConnectionId) ? (int) $runProviderConnectionId : null);
|
||||
|
||||
return $selectedProviderConnectionId !== null
|
||||
&& $runProviderConnectionId !== null
|
||||
&& $selectedProviderConnectionId === $runProviderConnectionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $state
|
||||
*/
|
||||
private function reviewStateWasConfirmed(TenantOnboardingSession $draft, array $state): bool
|
||||
{
|
||||
if (in_array($draft->current_step, ['bootstrap', 'complete'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$bootstrapRuns = $state['bootstrap_operation_runs'] ?? null;
|
||||
if (is_array($bootstrapRuns) && $bootstrapRuns !== []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$bootstrapTypes = $state['bootstrap_operation_types'] ?? null;
|
||||
|
||||
return is_array($bootstrapTypes) && $bootstrapTypes !== [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $state
|
||||
*/
|
||||
private function verificationRun(TenantOnboardingSession $draft, array $state): ?OperationRun
|
||||
{
|
||||
$runId = $state['verification_operation_run_id'] ?? null;
|
||||
$runId = is_int($runId) ? $runId : (is_numeric($runId) ? (int) $runId : null);
|
||||
|
||||
if (! is_int($runId) || $runId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return OperationRun::query()
|
||||
->whereKey($runId)
|
||||
->where('workspace_id', (int) $draft->workspace_id)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@ -26,7 +26,14 @@ enum AuditActionId: string
|
||||
// Managed tenant onboarding wizard.
|
||||
case ManagedTenantOnboardingStart = 'managed_tenant_onboarding.start';
|
||||
case ManagedTenantOnboardingResume = 'managed_tenant_onboarding.resume';
|
||||
case ManagedTenantOnboardingDraftSelected = 'managed_tenant_onboarding.draft_selected';
|
||||
case ManagedTenantOnboardingDraftUpdated = 'managed_tenant_onboarding.draft_updated';
|
||||
case ManagedTenantOnboardingProviderConnectionChanged = 'managed_tenant_onboarding.provider_connection_changed';
|
||||
case ManagedTenantOnboardingVerificationStart = 'managed_tenant_onboarding.verification_start';
|
||||
case ManagedTenantOnboardingVerificationPersisted = 'managed_tenant_onboarding.verification_persisted';
|
||||
case ManagedTenantOnboardingBootstrapStarted = 'managed_tenant_onboarding.bootstrap_started';
|
||||
case ManagedTenantOnboardingCancelled = 'managed_tenant_onboarding.cancelled';
|
||||
case ManagedTenantOnboardingActivationOverrideUsed = 'managed_tenant_onboarding.activation_override_used';
|
||||
case ManagedTenantOnboardingActivation = 'managed_tenant_onboarding.activation';
|
||||
case VerificationCompleted = 'verification.completed';
|
||||
case VerificationCheckAcknowledged = 'verification.check_acknowledged';
|
||||
@ -128,7 +135,14 @@ private static function labels(): array
|
||||
self::TenantMembershipLastOwnerBlocked->value => 'Tenant last-owner protection',
|
||||
self::ManagedTenantOnboardingStart->value => 'Managed tenant onboarding start',
|
||||
self::ManagedTenantOnboardingResume->value => 'Managed tenant onboarding resume',
|
||||
self::ManagedTenantOnboardingDraftSelected->value => 'Managed tenant onboarding draft selected',
|
||||
self::ManagedTenantOnboardingDraftUpdated->value => 'Managed tenant onboarding draft updated',
|
||||
self::ManagedTenantOnboardingProviderConnectionChanged->value => 'Managed tenant onboarding provider connection changed',
|
||||
self::ManagedTenantOnboardingVerificationStart->value => 'Managed tenant onboarding verification start',
|
||||
self::ManagedTenantOnboardingVerificationPersisted->value => 'Managed tenant onboarding verification persisted',
|
||||
self::ManagedTenantOnboardingBootstrapStarted->value => 'Managed tenant onboarding bootstrap started',
|
||||
self::ManagedTenantOnboardingCancelled->value => 'Managed tenant onboarding cancelled',
|
||||
self::ManagedTenantOnboardingActivationOverrideUsed->value => 'Managed tenant onboarding activation override used',
|
||||
self::ManagedTenantOnboardingActivation->value => 'Managed tenant onboarding activation',
|
||||
self::VerificationCompleted->value => 'Verification completed',
|
||||
self::VerificationCheckAcknowledged->value => 'Verification check acknowledged',
|
||||
|
||||
@ -32,6 +32,8 @@ class Capabilities
|
||||
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD_IDENTIFY = 'workspace_managed_tenant.onboard.identify';
|
||||
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD_CANCEL = 'workspace_managed_tenant.onboard.cancel';
|
||||
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW = 'workspace_managed_tenant.onboard.connection.view';
|
||||
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE = 'workspace_managed_tenant.onboard.connection.manage';
|
||||
|
||||
40
app/Support/Onboarding/OnboardingDraftStage.php
Normal file
40
app/Support/Onboarding/OnboardingDraftStage.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Onboarding;
|
||||
|
||||
enum OnboardingDraftStage: string
|
||||
{
|
||||
case Identify = 'identify';
|
||||
case ConnectProvider = 'connect-provider';
|
||||
case VerifyAccess = 'verify-access';
|
||||
case Bootstrap = 'bootstrap';
|
||||
case Review = 'review';
|
||||
case Completed = 'completed';
|
||||
case Cancelled = 'cancelled';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Identify => 'Identify managed tenant',
|
||||
self::ConnectProvider => 'Connect provider',
|
||||
self::VerifyAccess => 'Verify access',
|
||||
self::Bootstrap => 'Bootstrap',
|
||||
self::Review => 'Review',
|
||||
self::Completed => 'Completed',
|
||||
self::Cancelled => 'Cancelled',
|
||||
};
|
||||
}
|
||||
|
||||
public function wizardStep(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::Identify => 1,
|
||||
self::ConnectProvider => 2,
|
||||
self::VerifyAccess => 3,
|
||||
self::Bootstrap => 4,
|
||||
self::Review, self::Completed, self::Cancelled => 5,
|
||||
};
|
||||
}
|
||||
}
|
||||
26
app/Support/Onboarding/OnboardingDraftStatus.php
Normal file
26
app/Support/Onboarding/OnboardingDraftStatus.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Onboarding;
|
||||
|
||||
enum OnboardingDraftStatus: string
|
||||
{
|
||||
case Draft = 'draft';
|
||||
case Completed = 'completed';
|
||||
case Cancelled = 'cancelled';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Draft => 'Draft',
|
||||
self::Completed => 'Completed',
|
||||
self::Cancelled => 'Cancelled',
|
||||
};
|
||||
}
|
||||
|
||||
public function isResumable(): bool
|
||||
{
|
||||
return $this === self::Draft;
|
||||
}
|
||||
}
|
||||
@ -84,7 +84,7 @@ private static function definitions(): array
|
||||
return [
|
||||
[
|
||||
'key' => 'permissions.admin_consent',
|
||||
'title' => 'Admin consent granted',
|
||||
'title' => 'Required application permissions',
|
||||
'mode' => 'type',
|
||||
'type' => 'application',
|
||||
],
|
||||
|
||||
@ -226,7 +226,7 @@ private static function sanitizeChecks(array $checks): ?array
|
||||
|
||||
$sanitized[] = [
|
||||
'key' => $key,
|
||||
'title' => $title,
|
||||
'title' => self::normalizeCheckTitle($key, $title),
|
||||
'status' => $status,
|
||||
'severity' => $severity,
|
||||
'blocking' => $blocking,
|
||||
@ -240,6 +240,14 @@ private static function sanitizeChecks(array $checks): ?array
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
private static function normalizeCheckTitle(string $key, string $title): string
|
||||
{
|
||||
return match ($key) {
|
||||
'permissions.admin_consent' => 'Required application permissions',
|
||||
default => $title,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $evidence
|
||||
* @return array<int, array{kind: string, value: int|string}>
|
||||
|
||||
@ -24,6 +24,7 @@
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"pestphp/pest": "^4.1",
|
||||
"pestphp/pest-plugin-browser": "^4.1",
|
||||
"pestphp/pest-plugin-laravel": "^4.0"
|
||||
},
|
||||
"autoload": {
|
||||
|
||||
1481
composer.lock
generated
1481
composer.lock
generated
File diff suppressed because it is too large
Load Diff
125
database/factories/TenantOnboardingSessionFactory.php
Normal file
125
database/factories/TenantOnboardingSessionFactory.php
Normal file
@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<TenantOnboardingSession>
|
||||
*/
|
||||
class TenantOnboardingSessionFactory extends Factory
|
||||
{
|
||||
protected $model = TenantOnboardingSession::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$entraTenantId = fake()->uuid();
|
||||
$tenantName = fake()->company();
|
||||
|
||||
return [
|
||||
'workspace_id' => Workspace::factory(),
|
||||
'tenant_id' => null,
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'current_step' => 'identify',
|
||||
'state' => [
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'tenant_name' => $tenantName,
|
||||
'environment' => 'prod',
|
||||
],
|
||||
'started_by_user_id' => User::factory(),
|
||||
'updated_by_user_id' => User::factory(),
|
||||
'completed_at' => null,
|
||||
'cancelled_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function forWorkspace(Workspace $workspace): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function forTenant(Tenant $tenant): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'state' => array_merge(is_array($attributes['state'] ?? null) ? $attributes['state'] : [], [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'tenant_name' => (string) $tenant->name,
|
||||
'environment' => (string) ($tenant->environment ?? 'prod'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function startedBy(User $user): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'started_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updatedBy(User $user): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function withProviderConnection(int $providerConnectionId): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'current_step' => 'connection',
|
||||
'state' => array_merge(is_array($attributes['state'] ?? null) ? $attributes['state'] : [], [
|
||||
'provider_connection_id' => $providerConnectionId,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function withVerificationRun(int $operationRunId): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'current_step' => 'verify',
|
||||
'state' => array_merge(is_array($attributes['state'] ?? null) ? $attributes['state'] : [], [
|
||||
'verification_operation_run_id' => $operationRunId,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function reviewReady(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'current_step' => 'bootstrap',
|
||||
'state' => array_merge(is_array($attributes['state'] ?? null) ? $attributes['state'] : [], [
|
||||
'bootstrap_operation_types' => [],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function completed(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'current_step' => 'complete',
|
||||
'completed_at' => now(),
|
||||
'cancelled_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function cancelled(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'current_step' => 'cancelled',
|
||||
'completed_at' => null,
|
||||
'cancelled_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('managed_tenant_onboarding_sessions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('managed_tenant_onboarding_sessions', 'cancelled_at')) {
|
||||
Schema::table('managed_tenant_onboarding_sessions', function (Blueprint $table): void {
|
||||
$table->timestamp('cancelled_at')->nullable()->after('completed_at');
|
||||
});
|
||||
}
|
||||
|
||||
DB::statement('DROP INDEX IF EXISTS managed_tenant_onboarding_sessions_active_workspace_entra_unique');
|
||||
DB::statement('DROP INDEX IF EXISTS managed_tenant_onboarding_sessions_active_tenant_unique');
|
||||
|
||||
DB::statement('CREATE UNIQUE INDEX managed_tenant_onboarding_sessions_active_workspace_entra_unique ON managed_tenant_onboarding_sessions (workspace_id, entra_tenant_id) WHERE completed_at IS NULL AND cancelled_at IS NULL');
|
||||
DB::statement('CREATE UNIQUE INDEX managed_tenant_onboarding_sessions_active_tenant_unique ON managed_tenant_onboarding_sessions (tenant_id) WHERE completed_at IS NULL AND cancelled_at IS NULL AND tenant_id IS NOT NULL');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('managed_tenant_onboarding_sessions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::statement('DROP INDEX IF EXISTS managed_tenant_onboarding_sessions_active_workspace_entra_unique');
|
||||
DB::statement('DROP INDEX IF EXISTS managed_tenant_onboarding_sessions_active_tenant_unique');
|
||||
|
||||
DB::statement('CREATE UNIQUE INDEX managed_tenant_onboarding_sessions_active_workspace_entra_unique ON managed_tenant_onboarding_sessions (workspace_id, entra_tenant_id) WHERE completed_at IS NULL');
|
||||
DB::statement('CREATE UNIQUE INDEX managed_tenant_onboarding_sessions_active_tenant_unique ON managed_tenant_onboarding_sessions (tenant_id) WHERE completed_at IS NULL AND tenant_id IS NOT NULL');
|
||||
|
||||
if (Schema::hasColumn('managed_tenant_onboarding_sessions', 'cancelled_at')) {
|
||||
Schema::table('managed_tenant_onboarding_sessions', function (Blueprint $table): void {
|
||||
$table->dropColumn('cancelled_at');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
48
package-lock.json
generated
48
package-lock.json
generated
@ -12,6 +12,7 @@
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"laravel-vite-plugin": "^2.0.0",
|
||||
"pg": "^8.16.3",
|
||||
"playwright": "^1.58.2",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^7.0.7"
|
||||
}
|
||||
@ -2739,6 +2740,53 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"laravel-vite-plugin": "^2.0.0",
|
||||
"pg": "^8.16.3",
|
||||
"playwright": "^1.58.2",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^7.0.7"
|
||||
}
|
||||
|
||||
@ -11,6 +11,9 @@
|
||||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Browser">
|
||||
<directory>tests/Browser</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
|
||||
@ -11,7 +11,10 @@
|
||||
use App\Http\Controllers\TenantOnboardingController;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Onboarding\OnboardingDraftResolver;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use App\Support\Workspaces\WorkspaceResolver;
|
||||
use Filament\Http\Middleware\Authenticate as FilamentAuthenticate;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
@ -78,6 +81,22 @@
|
||||
return $workspace;
|
||||
});
|
||||
|
||||
Route::bind('onboardingDraft', function (string $value): TenantOnboardingSession {
|
||||
$user = auth()->user();
|
||||
|
||||
abort_unless($user instanceof \App\Models\User, 403);
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
abort_unless(is_int($workspaceId), 404);
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
abort_unless($workspace instanceof Workspace, 404);
|
||||
|
||||
return app(OnboardingDraftResolver::class)->resolve((int) $value, $user, $workspace);
|
||||
});
|
||||
|
||||
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-member'])
|
||||
->prefix('/admin/w/{workspace}')
|
||||
->group(function (): void {
|
||||
@ -98,6 +117,17 @@
|
||||
->get('/admin/onboarding', \App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class)
|
||||
->name('admin.onboarding');
|
||||
|
||||
Route::middleware([
|
||||
'web',
|
||||
'panel:admin',
|
||||
'ensure-correct-guard:web',
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
])
|
||||
->get('/admin/onboarding/{onboardingDraft}', \App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class)
|
||||
->name('admin.onboarding.draft');
|
||||
|
||||
Route::middleware([
|
||||
'web',
|
||||
'panel:admin',
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: Managed Tenant Onboarding Draft Identity & Resume Semantics
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to implementation planning and delivery
|
||||
**Created**: 2026-03-13
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No unresolved clarification markers remain
|
||||
- [x] Primary user value and enterprise trust problem are explicit
|
||||
- [x] All mandatory sections are completed
|
||||
- [x] Scope, non-goals, assumptions, and dependencies are documented
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] Functional requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Acceptance scenarios cover primary flows
|
||||
- [x] Edge cases are identified
|
||||
- [x] Refresh, resume, multi-draft, and non-rehydration semantics are specified
|
||||
- [x] Authorization semantics distinguish non-member `404` from in-scope member `403`
|
||||
- [x] Ownership exception for onboarding drafts is documented consistently with the constitution
|
||||
|
||||
## Cross-Artifact Readiness
|
||||
|
||||
- [x] Plan aligns with spec scope and constitution constraints
|
||||
- [x] Tasks cover routing, lifecycle, authorization, audit, and browser validation work
|
||||
- [x] Tasks explicitly cover summary or detail access for picker and non-resumable flows
|
||||
- [x] Tasks explicitly cover activation-guard persistence after refresh
|
||||
- [x] Tasks explicitly cover draft creation and confirmed draft update audit events
|
||||
|
||||
## Notes
|
||||
|
||||
- Validation pass completed on 2026-03-13 after resolving ownership, authorization, audit, picker-summary, and activation-refresh gaps.
|
||||
- `managed_tenant_onboarding_sessions` uses the constitution-approved workflow exception for nullable `tenant_id` while remaining workspace-scoped.
|
||||
- Spec is ready for implementation work once code changes begin.
|
||||
@ -0,0 +1,110 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Onboarding Draft Resume Contract
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Internal action contract for managed tenant onboarding draft landing, selection,
|
||||
canonical route loading, and lifecycle-safe resume semantics.
|
||||
servers:
|
||||
- url: https://tenantatlas.internal
|
||||
paths:
|
||||
/admin/onboarding:
|
||||
get:
|
||||
operationId: onboardingDraftLanding
|
||||
summary: Resolve onboarding landing state for the current workspace
|
||||
description: |
|
||||
Returns one of three outcomes for the current authorized workspace:
|
||||
empty start state, redirect to a single resumable draft, or a picker view
|
||||
when multiple resumable drafts exist.
|
||||
responses:
|
||||
'200':
|
||||
description: Landing state rendered directly
|
||||
'302':
|
||||
description: Redirect to canonical draft route when exactly one resumable draft exists
|
||||
'403':
|
||||
description: Returned when the actor is in scope but lacks onboarding capability
|
||||
'404':
|
||||
description: Returned when workspace context is missing or inaccessible
|
||||
/admin/onboarding/{onboardingDraft}:
|
||||
parameters:
|
||||
- name: onboardingDraft
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
get:
|
||||
operationId: showOnboardingDraft
|
||||
summary: Load one explicit onboarding draft
|
||||
description: |
|
||||
Loads the requested draft if it exists, belongs to the current workspace,
|
||||
is authorized for the actor, and is resumable or otherwise viewable.
|
||||
Resume stage must be derived from persisted confirmed data. Non-resumable
|
||||
drafts may render a non-editable summary surface instead of the active wizard.
|
||||
responses:
|
||||
'200':
|
||||
description: Draft wizard or non-resumable summary rendered
|
||||
'403':
|
||||
description: Actor is in scope but lacks capability to access the draft surface
|
||||
'404':
|
||||
description: Draft not found, outside workspace, or inaccessible
|
||||
patch:
|
||||
operationId: updateOnboardingDraft
|
||||
summary: Persist a confirmed onboarding draft transition
|
||||
description: |
|
||||
Persists a confirmed step boundary or explicit draft selection outcome.
|
||||
Unsaved transient edits are out of scope and are not persisted by this contract.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
current_step:
|
||||
type: string
|
||||
state:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
selected_provider_connection_id:
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
verification_operation_run_id:
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
responses:
|
||||
'200':
|
||||
description: Confirmed state persisted
|
||||
'403':
|
||||
description: Actor lacks capability for the attempted confirmed transition
|
||||
'404':
|
||||
description: Draft not found or inaccessible
|
||||
post:
|
||||
operationId: transitionOnboardingDraftLifecycle
|
||||
summary: Apply an explicit lifecycle transition such as cancel or complete
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- action
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum:
|
||||
- cancel
|
||||
- complete
|
||||
reason:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
responses:
|
||||
'200':
|
||||
description: Lifecycle transition applied
|
||||
'403':
|
||||
description: Actor lacks permission for the transition
|
||||
'404':
|
||||
description: Draft not found or inaccessible
|
||||
113
specs/138-managed-tenant-onboarding-draft-identity/data-model.md
Normal file
113
specs/138-managed-tenant-onboarding-draft-identity/data-model.md
Normal file
@ -0,0 +1,113 @@
|
||||
# Data Model: Managed Tenant Onboarding Draft Identity & Resume Semantics
|
||||
|
||||
## Primary Entity: Onboarding Draft
|
||||
|
||||
### Backing Model
|
||||
|
||||
- Existing model: `App\Models\TenantOnboardingSession`
|
||||
- Existing table: `managed_tenant_onboarding_sessions`
|
||||
- UI language: `Onboarding draft`
|
||||
|
||||
## Ownership & Authorization Model
|
||||
|
||||
- The onboarding draft is a workspace-scoped workflow-coordination record, not a generic tenant-owned domain record.
|
||||
- `workspace_id` is mandatory for every draft.
|
||||
- `tenant_id` remains nullable under the constitution-approved onboarding exception because drafts can exist before tenant identification.
|
||||
- Once `tenant_id` is attached, authorization must enforce both workspace entitlement and tenant entitlement for draft access.
|
||||
- Before `tenant_id` is attached, workspace entitlement is sufficient.
|
||||
|
||||
## Entity Fields
|
||||
|
||||
### Existing or Required Core Fields
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `id` | bigint | Stable draft identity used in canonical route binding |
|
||||
| `workspace_id` | bigint | Workspace isolation boundary |
|
||||
| `tenant_id` | bigint nullable | Managed tenant database record when identified |
|
||||
| `entra_tenant_id` | string nullable | External tenant identity used before or during tenant materialization |
|
||||
| `started_by_user_id` | bigint nullable | Attribution for who created the draft |
|
||||
| `updated_by_user_id` | bigint nullable | Attribution for who last confirmed a change |
|
||||
| `current_step` | string nullable | Diagnostic and audit marker only |
|
||||
| `state` | json nullable | Confirmed non-secret draft state |
|
||||
| `completed_at` | timestamp nullable | Marks the draft as completed |
|
||||
| `cancelled_at` | timestamp nullable | Marks the draft as cancelled |
|
||||
| `created_at` | timestamp | Created timestamp |
|
||||
| `updated_at` | timestamp | Last persisted update timestamp |
|
||||
|
||||
### Recommended State Payload Keys
|
||||
|
||||
Confirmed draft state should continue to allow only non-secret, confirmed keys such as:
|
||||
|
||||
- `entra_tenant_id`
|
||||
- `tenant_id`
|
||||
- `tenant_name`
|
||||
- `environment`
|
||||
- `primary_domain`
|
||||
- `notes`
|
||||
- `provider_connection_id`
|
||||
- `selected_provider_connection_id`
|
||||
- `verification_operation_run_id`
|
||||
- `verification_run_id`
|
||||
- `bootstrap_operation_types`
|
||||
- `bootstrap_operation_runs`
|
||||
- `bootstrap_run_ids`
|
||||
|
||||
## Lifecycle Semantics
|
||||
|
||||
### Resumable
|
||||
|
||||
A draft is resumable when all of the following are true:
|
||||
|
||||
- It belongs to the current workspace.
|
||||
- It is authorized for the current actor.
|
||||
- It is not completed.
|
||||
- It is not cancelled.
|
||||
|
||||
### Non-Resumable
|
||||
|
||||
A draft is non-resumable when any of the following are true:
|
||||
|
||||
- It is completed.
|
||||
- It is cancelled.
|
||||
- It is outside the current workspace.
|
||||
- It is inaccessible due to authorization.
|
||||
|
||||
## Derived Projections
|
||||
|
||||
### Draft Stage
|
||||
|
||||
Derived from confirmed state, not only from `current_step`:
|
||||
|
||||
1. No identified tenant data: `identify`
|
||||
2. Tenant identified but no provider connection selected: `connect-provider`
|
||||
3. Provider connection selected but verification cannot proceed or is incomplete: `verify-access`
|
||||
4. Verification complete but bootstrap pending: `bootstrap`
|
||||
5. Bootstrap confirmed and ready for final review or activation: `review`
|
||||
6. Completed: `completed`
|
||||
7. Cancelled: `cancelled`
|
||||
|
||||
### Draft Summary Metadata
|
||||
|
||||
Used by the landing picker and header banner:
|
||||
|
||||
- Tenant display name
|
||||
- External tenant identifier
|
||||
- Environment
|
||||
- Current stage label
|
||||
- Started by display name
|
||||
- Last updated by display name
|
||||
- Last updated at
|
||||
- Draft age
|
||||
- Verification stale or blocked hint
|
||||
|
||||
## Sensitive Data Rule
|
||||
|
||||
The following values must never be stored in the draft payload for UI rehydration:
|
||||
|
||||
- Client secrets
|
||||
- Raw credential material
|
||||
- Temporary authorization tokens
|
||||
- Any secret provider credential value
|
||||
|
||||
The draft may store references to related provider connections or verification runs, but not secrets.
|
||||
150
specs/138-managed-tenant-onboarding-draft-identity/plan.md
Normal file
150
specs/138-managed-tenant-onboarding-draft-identity/plan.md
Normal file
@ -0,0 +1,150 @@
|
||||
# Implementation Plan: Managed Tenant Onboarding Draft Identity & Resume Semantics
|
||||
|
||||
**Branch**: `138-managed-tenant-onboarding-draft-identity` | **Date**: 2026-03-13 | **Spec**: [specs/138-managed-tenant-onboarding-draft-identity/spec.md](spec.md)
|
||||
**Input**: Feature specification from `specs/138-managed-tenant-onboarding-draft-identity/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Harden the managed tenant onboarding wizard around explicit draft identity, canonical draft routing, deterministic DB-backed resume semantics, and enterprise-safe lifecycle handling.
|
||||
|
||||
The implementation reuses `TenantOnboardingSession` as the persisted onboarding draft backbone, adds explicit draft-route semantics, replaces resume heuristics with data-derived draft loading, introduces an onboarding landing or picker experience with explicit summary access, formalizes resumable versus non-resumable draft behavior, and adds browser-level regression coverage for refresh, multi-draft flows, and activation-guard persistence.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Laravel Sail, Pest v4, PHPUnit v12
|
||||
**Storage**: PostgreSQL via Laravel migrations and JSON-backed state on `managed_tenant_onboarding_sessions`
|
||||
**Testing**: Pest feature, unit, Livewire, and browser tests via `vendor/bin/sail artisan test --compact`
|
||||
**Target Platform**: Laravel web application with Filament admin pages and workspace-scoped authorization
|
||||
**Project Type**: Web application
|
||||
**Performance Goals**: Draft landing and draft-load routes remain DB-only at render time; resume-step derivation remains constant-time from confirmed persisted data; refresh must not trigger new remote verification work
|
||||
**Constraints**: No secret rehydration; no new inline remote work during page render; non-members and cross-workspace requests remain deny-as-not-found; in-scope members lacking onboarding capability remain `403`; completed and cancelled drafts remain non-editable; confirmation is required for destructive lifecycle actions
|
||||
**Scale/Scope**: Cross-cutting onboarding change touching routing, wizard initialization, draft persistence, authorization, audit logging, and browser regression coverage
|
||||
|
||||
### Filament v5 Implementation Notes
|
||||
|
||||
- **Livewire v4.0+ compliance**: Maintained. The managed tenant onboarding wizard remains a Livewire-backed Filament page and stays compatible with Livewire v4.
|
||||
- **Provider registration location**: No new panel is introduced. Existing panel providers remain registered in `bootstrap/providers.php`.
|
||||
- **Global search rule**: This feature does not introduce a new globally searchable resource. Existing global-search behavior remains unchanged.
|
||||
- **Destructive actions**: Any new cancel-draft or terminate-draft controls must execute through confirmed Filament actions with server-side authorization.
|
||||
- **Asset strategy**: No new Filament assets are planned. Existing deployment practice still includes `php artisan filament:assets`, but this feature does not add new asset registrations.
|
||||
- **Testing plan**: Add focused feature or Livewire tests for landing and draft routes, unit tests for resume-step derivation and draft-query helpers, and mandatory browser tests for hard refresh and multi-draft selection.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before implementation begins. Re-check after design changes.*
|
||||
|
||||
- Inventory-first: PASS. The feature hardens onboarding flow semantics and does not change inventory ownership.
|
||||
- Read/write separation: PASS. Draft state persists only at explicit confirmation boundaries instead of keystroke autosave.
|
||||
- Graph contract path: PASS. No new Graph call path is introduced.
|
||||
- Deterministic capabilities: PASS. Access remains routed through canonical workspace and onboarding capability checks.
|
||||
- Scope & ownership clarification: PASS WITH EXPLICIT EXCEPTION. `managed_tenant_onboarding_sessions` use the constitution-approved workflow exception for nullable `tenant_id`; workspace scope remains primary and tenant entitlement applies once a tenant is attached.
|
||||
- Workspace isolation: PASS. Draft discovery and access remain scoped to the current workspace and fail closed on mismatch.
|
||||
- Tenant isolation: PASS. Draft-linked tenant references remain validated against workspace context.
|
||||
- RBAC-UX 404/403 semantics: PASS. Non-members and out-of-scope actors remain 404; established in-scope members without onboarding capability remain 403.
|
||||
- Destructive confirmation standard: PASS WITH WORK. Any cancel-draft action must use `->requiresConfirmation()` plus server-side authorization.
|
||||
- Ops-UX: PASS. Verification and bootstrap operations keep existing `OperationRun` semantics and are not moved to render-time execution.
|
||||
- UI naming: PASS WITH WORK. Operator copy must consistently refer to `Onboarding draft`, `Resume onboarding draft`, and `Start new onboarding`.
|
||||
- Filament UI action surface contract: PASS WITH WORK. The landing route requires a draft-selection surface with clear actions and safe empty states.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/138-managed-tenant-onboarding-draft-identity/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── onboarding-draft-resume.openapi.yaml
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ └── Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||
├── Http/
|
||||
│ ├── Controllers/
|
||||
│ │ └── TenantOnboardingController.php
|
||||
│ └── Middleware/
|
||||
│ └── EnsureWorkspaceSelected.php
|
||||
├── Models/
|
||||
│ ├── Tenant.php
|
||||
│ └── TenantOnboardingSession.php
|
||||
├── Policies/
|
||||
├── Services/
|
||||
│ ├── Audit/
|
||||
│ └── Verification/
|
||||
└── Support/
|
||||
├── Auth/
|
||||
└── Ui/
|
||||
|
||||
database/
|
||||
├── factories/
|
||||
└── migrations/
|
||||
|
||||
routes/
|
||||
└── web.php
|
||||
|
||||
tests/
|
||||
├── Browser/
|
||||
├── Feature/
|
||||
│ ├── Onboarding/
|
||||
│ └── Audit/
|
||||
└── Unit/
|
||||
└── Onboarding/
|
||||
```
|
||||
|
||||
**Structure Decision**: Keep the current Laravel and Filament structure. The existing onboarding page remains the primary UI surface; draft identity, route resolution, and resume derivation are extracted into small dedicated helpers or services rather than creating a second wizard stack.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| N/A | N/A | N/A |
|
||||
|
||||
## Phase 0 — Research (complete)
|
||||
|
||||
- Output: [specs/138-managed-tenant-onboarding-draft-identity/research.md](research.md)
|
||||
- Selected the hybrid model: explicit draft identity in URL, DB-backed confirmed state as source of truth, data-derived wizard resume.
|
||||
- Rejected heuristic-only resume, creator-only draft visibility, autosave, and edit locking for v1.
|
||||
- Confirmed current code hotspots: `ManagedTenantOnboardingWizard` currently auto-resumes only when one incomplete session exists and computes start step from live state instead of explicit draft-route identity.
|
||||
|
||||
## Phase 1 — Design & Contracts (complete)
|
||||
|
||||
- Output: [specs/138-managed-tenant-onboarding-draft-identity/data-model.md](data-model.md)
|
||||
- Output: [specs/138-managed-tenant-onboarding-draft-identity/quickstart.md](quickstart.md)
|
||||
- Output: [specs/138-managed-tenant-onboarding-draft-identity/contracts/onboarding-draft-resume.openapi.yaml](contracts/onboarding-draft-resume.openapi.yaml)
|
||||
|
||||
### Post-design Constitution Re-check
|
||||
|
||||
- PASS: Confirmed-state durability is preserved without autosaving transient edits.
|
||||
- PASS: Workspace-scoped resume remains deny-as-not-found on mismatch, while in-scope capability denial remains 403.
|
||||
- PASS WITH WORK: Draft cancellation and non-resumable handling require explicit policy and confirmed actions.
|
||||
- PASS WITH WORK: Browser-level tests are mandatory because the trust gap is driven by real refresh behavior.
|
||||
|
||||
## Phase 2 — Implementation Planning
|
||||
|
||||
`tasks.md` covers:
|
||||
|
||||
- Route and model groundwork for explicit draft identity and lifecycle.
|
||||
- Landing, redirect, picker, and canonical draft-route semantics.
|
||||
- Data-derived resume logic, orientation banner behavior, and explicit summary or detail access from the picker.
|
||||
- Explicit non-resumable handling, attribution, cancel rules, and refresh-safe activation-guard persistence.
|
||||
- Feature, unit, and browser coverage for `404` versus `403`, refresh, multi-draft, multi-operator, and activation-guard behavior.
|
||||
|
||||
### Contract Implementation Note
|
||||
|
||||
- The contract describes internal route and action semantics for onboarding draft selection, loading, creation, update, cancellation, and completion.
|
||||
- Existing Filament page handlers and route definitions may satisfy the contract as long as they preserve the documented authorization, resume, audit, and fail-closed semantics.
|
||||
|
||||
### Deployment Sequencing Note
|
||||
|
||||
- Schema changes, if required, remain additive and reversible.
|
||||
- Draft lifecycle backfill or normalization must not run inside schema migrations unless it is purely deterministic and side-effect-free.
|
||||
- Browser and focused feature coverage should pass before any rollout of the route switch from landing-only to canonical draft URLs.
|
||||
@ -0,0 +1,56 @@
|
||||
# Quickstart: Managed Tenant Onboarding Draft Identity & Resume Semantics
|
||||
|
||||
## Scenario 1: Start a new onboarding draft
|
||||
|
||||
1. Visit `/admin/onboarding` in a workspace with no open onboarding drafts.
|
||||
2. Confirm the page shows a clean start state and no misleading resume banner.
|
||||
3. Complete and confirm Step 1.
|
||||
4. Confirm the browser redirects to `/admin/onboarding/{draft}`.
|
||||
5. Refresh the page and confirm the identified tenant information still appears.
|
||||
|
||||
## Scenario 2: Resume the only open draft
|
||||
|
||||
1. Seed exactly one resumable onboarding draft in the current workspace.
|
||||
2. Visit `/admin/onboarding`.
|
||||
3. Confirm automatic redirect to `/admin/onboarding/{draft}`.
|
||||
4. Confirm the wizard displays a resume banner with tenant name, stage, and attribution.
|
||||
|
||||
## Scenario 3: Choose among multiple drafts
|
||||
|
||||
1. Seed multiple resumable drafts in the same workspace.
|
||||
2. Visit `/admin/onboarding`.
|
||||
3. Confirm a draft picker appears instead of a blank Step 1 or silent redirect.
|
||||
4. Resume one draft.
|
||||
5. Confirm the chosen draft opens on the correct derived stage.
|
||||
|
||||
## Scenario 4: Hard refresh after provider selection
|
||||
|
||||
1. Open a concrete draft URL where Step 1 is confirmed and a provider connection has been selected.
|
||||
2. Hard-refresh the browser.
|
||||
3. Confirm the same draft URL remains loaded.
|
||||
4. Confirm the selected provider connection and derived stage are restored.
|
||||
5. Confirm any secret entry field is empty after reload.
|
||||
|
||||
## Scenario 5: Completed or cancelled draft direct access
|
||||
|
||||
1. Mark a draft as completed or cancelled.
|
||||
2. Visit `/admin/onboarding/{draft}` directly.
|
||||
3. Confirm the draft does not reopen in editable wizard mode.
|
||||
4. Confirm the user sees a safe summary or non-resumable state.
|
||||
|
||||
## Scenario 6: Cross-operator continuity
|
||||
|
||||
1. Create a resumable draft as one authorized operator.
|
||||
2. Sign in as another authorized operator in the same workspace.
|
||||
3. Visit `/admin/onboarding` and resume the draft.
|
||||
4. Confirm started-by and last-updated-by attribution are visible and updated appropriately.
|
||||
|
||||
## Scenario 7: Access semantics and activation guards survive refresh
|
||||
|
||||
1. Attempt to load a concrete draft URL as a non-member or with the wrong workspace selected.
|
||||
2. Confirm the response remains deny-as-not-found.
|
||||
3. Attempt to load the same draft as an in-scope workspace member without onboarding capability.
|
||||
4. Confirm the response is policy-consistent `403`.
|
||||
5. Open a draft where activation is still blocked by verification or override requirements.
|
||||
6. Hard-refresh the draft URL.
|
||||
7. Confirm the activation guard, confirmation requirements, and override requirements remain in force after reload.
|
||||
@ -0,0 +1,44 @@
|
||||
# Research: Managed Tenant Onboarding Draft Identity & Resume Semantics
|
||||
|
||||
## Decision 1: Use explicit draft identity in the URL
|
||||
|
||||
- **Decision**: Use a hybrid model with `/admin/onboarding` as a landing route and `/admin/onboarding/{onboardingDraft}` as the canonical route for an active draft.
|
||||
- **Rationale**: This gives operators a concrete mental model, makes refresh deterministic, and removes silent wizard-state heuristics as the primary resume mechanism.
|
||||
- **Alternatives considered**:
|
||||
- Keep landing-only heuristic resume: rejected because it is ambiguous and not bookmarkable.
|
||||
- Query parameter only: acceptable technically, but rejected as the primary enterprise representation because the path route is clearer and easier to reason about.
|
||||
|
||||
## Decision 2: Resume stage comes from persisted state, not only `current_step`
|
||||
|
||||
- **Decision**: Derive the resumed wizard step from persisted confirmed data completeness plus verification and bootstrap state.
|
||||
- **Rationale**: Persisted data is more trustworthy than a stale step marker and tolerates partial inconsistencies better.
|
||||
- **Alternatives considered**:
|
||||
- Resume purely from `current_step`: rejected because it can lie when required state is missing.
|
||||
|
||||
## Decision 3: Keep unsaved edits transient
|
||||
|
||||
- **Decision**: Persist only at explicit step-confirmation boundaries. Unsaved current-step edits may be lost on refresh.
|
||||
- **Rationale**: This keeps the model understandable, avoids partial write complexity, and matches the enterprise requirement that confirmed work be durable without implying autosave.
|
||||
- **Alternatives considered**:
|
||||
- Autosave every input: rejected as out of scope and higher risk for misleading partial persistence semantics.
|
||||
|
||||
## Decision 4: Use workspace-shared draft visibility for continuity
|
||||
|
||||
- **Decision**: Authorized onboarding operators in the current workspace may see resumable drafts, regardless of creator.
|
||||
- **Rationale**: Enterprise admin workflows require continuity across operators and should not become fragile when one user is unavailable.
|
||||
- **Alternatives considered**:
|
||||
- Creator-only visibility: rejected because it harms operational continuity and creates unnecessary support burden.
|
||||
|
||||
## Decision 5: Completed and cancelled drafts are non-resumable
|
||||
|
||||
- **Decision**: Completed and cancelled drafts remain historical or summary records and do not reopen in edit mode.
|
||||
- **Rationale**: This avoids unsafe reopening and preserves operator trust around lifecycle meaning.
|
||||
- **Alternatives considered**:
|
||||
- Allow reopening completed drafts: rejected because it blurs activation completion semantics.
|
||||
|
||||
## Decision 6: Browser-level validation is mandatory
|
||||
|
||||
- **Decision**: Add browser tests for hard refresh on the canonical draft route.
|
||||
- **Rationale**: The main trust defect is visible only in real reload behavior, not just unit or Livewire state transitions.
|
||||
- **Alternatives considered**:
|
||||
- Feature tests only: rejected because they do not prove real browser reload semantics.
|
||||
208
specs/138-managed-tenant-onboarding-draft-identity/spec.md
Normal file
208
specs/138-managed-tenant-onboarding-draft-identity/spec.md
Normal file
@ -0,0 +1,208 @@
|
||||
# Feature Specification: Managed Tenant Onboarding Draft Identity & Resume Semantics
|
||||
|
||||
**Feature Branch**: `138-managed-tenant-onboarding-draft-identity`
|
||||
**Created**: 2026-03-13
|
||||
**Status**: Proposed
|
||||
**Input**: User description: "Spec 138 — Managed Tenant Onboarding Draft Identity & Resume Semantics"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace
|
||||
- **Primary Routes**:
|
||||
- `/admin/onboarding`
|
||||
- `/admin/onboarding/{onboardingDraft}`
|
||||
- **Data Ownership**:
|
||||
- `managed_tenant_onboarding_sessions` remain workspace-scoped onboarding draft workflow records represented in the UI as onboarding drafts
|
||||
- The draft model uses the constitution-approved workflow exception: `workspace_id` is always required and `tenant_id` remains nullable until identification, then acts as an authorization and continuity reference rather than redefining the draft as a generic tenant-owned record
|
||||
- Confirmed onboarding state remains DB-backed and tenant-aware, while transient unsaved step input remains Livewire-only
|
||||
- Provider connection, verification, bootstrap, and activation references remain existing operational records linked from the draft rather than copied into parallel shadow objects
|
||||
- **RBAC**:
|
||||
- Workspace membership remains mandatory for all onboarding draft discovery and access
|
||||
- Users with onboarding management capability may view and resume resumable drafts in their current workspace
|
||||
- Non-members, cross-workspace requests, and actors lacking tenant entitlement for an identified draft remain deny-as-not-found
|
||||
- Established in-scope workspace members who lack onboarding capability receive policy-consistent `403` denial on execution or page access
|
||||
- Destructive lifecycle actions such as cancelling a draft remain capability-gated, confirmed, and auditable
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Deterministic draft identity and routing (Priority: P1)
|
||||
|
||||
As an authorized workspace operator, I want every onboarding draft to have an explicit identity and canonical URL so that refresh and revisit always reopen the same work item instead of relying on heuristics.
|
||||
|
||||
**Why this priority**: Without explicit draft identity, the onboarding wizard remains untrustworthy on refresh, revisit, and bookmarks.
|
||||
|
||||
**Independent Test**: Visit `/admin/onboarding`, create or resume a draft, confirm that the browser moves to `/admin/onboarding/{draft}`, then reload and verify that the same draft and confirmed state are restored.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** no resumable drafts exist in the current workspace, **When** the user visits `/admin/onboarding`, **Then** the landing experience shows a clean start state for a new onboarding draft.
|
||||
2. **Given** exactly one resumable draft exists in the current workspace, **When** the user visits `/admin/onboarding`, **Then** the request redirects to `/admin/onboarding/{draft}` for that draft.
|
||||
3. **Given** a concrete draft route is visited, **When** the draft is valid and accessible, **Then** the wizard loads confirmed state from the database and remains anchored to that draft identity across hard refresh.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Clear resume and multi-draft behavior (Priority: P1)
|
||||
|
||||
As an operator, I want the system to make ambiguity explicit when multiple drafts exist so that I never lose trust because the wizard silently chooses the wrong draft or falls back to blank Step 1.
|
||||
|
||||
**Why this priority**: Enterprise operators need a reliable mental model of which draft they are resuming and why.
|
||||
|
||||
**Independent Test**: Seed multiple open drafts in one workspace, visit `/admin/onboarding`, confirm that a draft picker appears, then choose a draft and verify the wizard shows clear resume context and the correct derived step.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** multiple resumable drafts exist, **When** the user lands on `/admin/onboarding`, **Then** the UI shows a draft picker and does not silently choose one.
|
||||
2. **Given** a user resumes a draft from the picker, **When** the wizard opens, **Then** it displays a resume banner with tenant identity, current stage, and attribution metadata.
|
||||
3. **Given** an existing resumable draft already targets the same managed tenant, **When** the user tries to create another one, **Then** the UI warns and offers resume-first behavior before creating a duplicate.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Enterprise lifecycle, visibility, and safety (Priority: P2)
|
||||
|
||||
As a workspace onboarding manager, I want draft lifecycle state, attribution, cancellation, and non-resumable handling to be explicit so that teams can share continuity without unsafe reopening of completed or cancelled work.
|
||||
|
||||
**Why this priority**: Shared operational continuity requires visible attribution and clear resumable versus historical state.
|
||||
|
||||
**Independent Test**: Resume a draft started by another authorized operator, cancel a draft, complete a draft, and verify the list, direct URL behavior, and authorization rules all behave deterministically.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a resumable draft was started by another authorized workspace operator, **When** a second authorized operator resumes it, **Then** the draft remains accessible and the UI shows started-by and last-updated-by attribution.
|
||||
2. **Given** a draft is cancelled or completed, **When** a user visits its direct route, **Then** the system does not reopen the editable wizard and instead fails closed into non-editable handling.
|
||||
3. **Given** a user lacks workspace membership or tenant entitlement for an identified draft, **When** they attempt to load a draft route, **Then** the system responds with policy-consistent deny-as-not-found behavior.
|
||||
4. **Given** a user is an established in-scope workspace member but lacks onboarding capability, **When** they attempt to load or mutate a draft, **Then** the system responds with policy-consistent `403` denial.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Verified refresh and resume behavior under real browser conditions (Priority: P2)
|
||||
|
||||
As a product owner, I want browser-level regression coverage for refresh, multi-tab, and stale verification behavior so that future changes cannot reintroduce progress-loss confusion.
|
||||
|
||||
**Why this priority**: The defect only becomes trustworthy when hard refresh is validated in a real browser and multi-tab behavior is deterministic.
|
||||
|
||||
**Independent Test**: Run browser tests that create or resume a draft on `/admin/onboarding/{draft}`, reload the page, revisit the URL, and assert that confirmed state, verification warnings, and non-rehydrated secrets behave correctly.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the user hard-refreshes a draft URL after confirming Step 1 or Step 2, **When** the page reloads, **Then** confirmed state is restored and the wizard resumes on the correct derived step.
|
||||
2. **Given** the same draft is open in two tabs, **When** later confirmed writes occur, **Then** state remains deterministic and business invariants stay protected.
|
||||
3. **Given** verification becomes stale because the selected provider connection changed, **When** the draft is revisited, **Then** the stale verification warning remains visible and activation guards still apply.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Zero open drafts in a workspace.
|
||||
- Exactly one open draft in a workspace.
|
||||
- Multiple open drafts in a workspace.
|
||||
- Multiple drafts for the same target tenant.
|
||||
- Draft created by one operator and resumed by another authorized operator.
|
||||
- Completed draft direct URL access.
|
||||
- Cancelled draft direct URL access.
|
||||
- Stale verification after provider connection change.
|
||||
- Same draft open in multiple tabs.
|
||||
- Refresh during verification or bootstrap.
|
||||
- Workspace switch while a concrete draft URL is open.
|
||||
- Unauthorized or cross-workspace draft access.
|
||||
- Old bookmarks targeting a draft that has since completed.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature hardens an existing onboarding workflow without changing Graph contract ownership. It formalizes onboarding drafts as first-class workspace-scoped workflow records, uses the constitution-approved `managed_tenant_onboarding_sessions` exception for nullable `tenant_id` continuity, keeps confirmed progress DB-backed, preserves read/write separation by only persisting at explicit step-confirmation boundaries, and requires audit coverage plus focused tests for all lifecycle-changing actions.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** Workspace membership and onboarding capability remain the source of truth. Non-members, cross-workspace requests, and actors lacking tenant entitlement for an identified draft remain deny-as-not-found. Established in-scope members who lack onboarding capability must receive `403`. Authorized workspace operators may view and resume workspace drafts for operational continuity, while destructive lifecycle actions such as cancel must remain server-authorized, confirmation-gated, and audited.
|
||||
|
||||
**Constitution alignment (OPS-UX):** Verification and bootstrap flows that already create or reuse `OperationRun` records keep the existing Ops-UX contract. This spec changes only resume semantics and draft anchoring; it does not introduce inline remote work during page render or new ad-hoc notification patterns.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Operator-facing copy must consistently use terms such as `Onboarding draft`, `Resume onboarding draft`, `Start new onboarding`, `Current stage`, `Started by`, and `Last updated by`. Internal implementation terms like session, payload, or heuristic must not become primary operator-facing labels.
|
||||
|
||||
**Constitution alignment (Filament v5):** Livewire v4.0+ remains the compatibility target. No new panel is introduced, so provider registration remains unchanged in `bootstrap/providers.php`. Any destructive lifecycle actions added to Filament surfaces must execute through confirmed actions with server-side authorization. No new global search behavior is required by this spec.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-138-01 Canonical draft identity**: The system must treat each managed tenant onboarding draft as a first-class persisted object with an explicit identity.
|
||||
- **FR-138-02 Canonical landing route**: The system must support `/admin/onboarding` as the landing route for starting, resuming, or selecting onboarding drafts.
|
||||
- **FR-138-03 Canonical draft route**: The system must support `/admin/onboarding/{onboardingDraft}` as the canonical route for operating on one explicit onboarding draft.
|
||||
- **FR-138-04 Landing zero-draft behavior**: When no resumable drafts exist in the current workspace, `/admin/onboarding` must show a fresh onboarding start state.
|
||||
- **FR-138-05 Landing single-draft behavior**: When exactly one resumable draft exists in the current workspace, `/admin/onboarding` must redirect to that concrete draft route.
|
||||
- **FR-138-06 Landing multi-draft behavior**: When multiple resumable drafts exist, `/admin/onboarding` must show a draft-selection UI and must not silently pick one or silently restart a blank wizard.
|
||||
- **FR-138-07 Fail-closed draft access**: Draft route access must fail closed: non-existent drafts, cross-workspace requests, and actors lacking scope entitlement must return `404`, while established in-scope members lacking onboarding capability must return policy-consistent `403`.
|
||||
- **FR-138-08 Explicit resumable lifecycle**: Drafts must distinguish resumable versus non-resumable states, with at least `draft`, `completed`, and `cancelled` lifecycle semantics.
|
||||
- **FR-138-09 Non-resumable drafts stay non-editable**: Completed and cancelled drafts must not reopen the editable wizard and must not appear in the open draft picker.
|
||||
- **FR-138-10 Data-derived step resume**: Resume step selection must be derived from persisted confirmed data and workflow state, not solely from `current_step`.
|
||||
- **FR-138-11 `current_step` remains diagnostic**: The draft may continue storing `current_step`, but it must remain diagnostic or audit metadata rather than the sole source of resume truth.
|
||||
- **FR-138-12 Confirmed state durability**: Once a step is confirmed and persisted, refresh and revisit must restore that confirmed state from the database.
|
||||
- **FR-138-13 Transient unsaved edits remain transient**: Unsaved edits on the current step may be lost on hard refresh and must not be represented to operators as durable.
|
||||
- **FR-138-14 Sensitive fields never rehydrate**: Secrets, raw credential material, and temporary authorization material must never be restored into UI fields from draft or session state.
|
||||
- **FR-138-15 Provider references may restore**: Non-secret references such as selected provider connection, verification references, and bootstrap references may be restored from confirmed draft state.
|
||||
- **FR-138-16 Orientation banner**: The wizard must show explicit resume orientation for concrete drafts, including tenant identity, current stage, and attribution metadata.
|
||||
- **FR-138-17 Duplicate warning**: When a resumable draft already exists for the same target tenant, the UI must warn and prefer resume-first behavior instead of blind duplicate creation.
|
||||
- **FR-138-18 Explicit new draft intent**: Starting a new draft while another resumable draft exists must be an explicit user action.
|
||||
- **FR-138-19 Post-step-1 canonicalization**: Once Step 1 identifies and confirms the target tenant, the flow must create or attach the correct draft and redirect the browser to the canonical draft URL.
|
||||
- **FR-138-20 Draft list metadata**: The draft picker must show tenant name, tenant identifier, environment if available, current stage, status, started by, last updated by, last updated at, and draft age.
|
||||
- **FR-138-21 Draft actions**: Draft list entries must support explicit resume, explicit non-editable detail or summary access, and may support cancel when authorized.
|
||||
- **FR-138-22 Workspace-shared visibility**: Authorized onboarding operators may view resumable drafts within their current workspace for operational continuity.
|
||||
- **FR-138-23 Attribution**: The UI must display who started and last updated each resumable draft.
|
||||
- **FR-138-24 Audit coverage**: The system must record or preserve meaningful audit events for draft creation, explicit resume, explicit draft selection, confirmed draft updates, provider connection changes, verification start and result persistence, bootstrap initiation, cancellation, completion, and blocked activation override use.
|
||||
- **FR-138-25 Refresh-safe verification semantics**: Persisted verification and bootstrap references must survive refresh and revisit, including stale verification indicators where applicable.
|
||||
- **FR-138-26 Activation safety preserved**: Refresh and revisit must not bypass existing activation safeguards, blocked-verification override requirements, confirmations, or authorization.
|
||||
- **FR-138-27 Multiple-tab determinism**: Same-draft multi-tab usage must be deterministic, with last confirmed write winning while server-side validation protects invariants.
|
||||
- **FR-138-28 Browser regression coverage**: Browser tests must validate the hard-refresh experience on a concrete draft URL.
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- Keystroke-level autosave of all in-progress inputs.
|
||||
- Collaborative real-time editing.
|
||||
- Distributed locking or optimistic locking.
|
||||
- Cross-workspace draft portability.
|
||||
- A generic workflow engine abstraction.
|
||||
- A full onboarding history dashboard beyond draft selection and resume needs.
|
||||
- Changes to the underlying verification or activation business rules beyond resume semantics.
|
||||
|
||||
### Assumptions
|
||||
|
||||
- `TenantOnboardingSession` remains the underlying persisted model initially, even if the UX refers to it as an onboarding draft.
|
||||
- The current onboarding wizard already persists meaningful confirmed state and can be refactored to anchor around explicit draft identity.
|
||||
- Existing provider verification and activation policies remain in place and are only surfaced more deterministically through draft routing and resume semantics.
|
||||
- Workspace-scoped operational continuity is preferred over creator-only visibility for resumable drafts.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Existing managed tenant onboarding wizard page and route registration.
|
||||
- Existing `TenantOnboardingSession` model and `managed_tenant_onboarding_sessions` table.
|
||||
- Existing provider connection, verification, bootstrap, activation, and audit logging infrastructure.
|
||||
- Existing workspace membership and onboarding capability enforcement.
|
||||
- Existing browser and Livewire testing infrastructure.
|
||||
|
||||
### Contract Interpretation
|
||||
|
||||
- The internal contract for this feature defines landing, load, resume, create, and lifecycle semantics for onboarding drafts.
|
||||
- Existing Filament pages, Livewire handlers, and controller endpoints may satisfy the contract as long as they preserve the documented route, authorization, resume, audit, and fail-closed semantics.
|
||||
- This spec prefers adapting the current onboarding page and route structure rather than introducing a second wizard implementation.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Onboarding landing | `/admin/onboarding` | Start new onboarding | Draft list rows when multiple drafts exist | Resume, More | None | Start managed tenant onboarding | None | Continue and Cancel within wizard only | Yes | Auto-redirect is allowed only when exactly one resumable draft exists. |
|
||||
| Concrete onboarding draft wizard | `/admin/onboarding/{draft}` | Resume context actions only | Stepper plus status banner | None | None | Not applicable | Verify, Bootstrap, Activate as already supported | Continue and Cancel | Yes | Sensitive fields stay empty after reload; destructive actions require confirmation. |
|
||||
| Draft picker list | Landing screen when multiple drafts exist | Start new onboarding | Row click-through to detail or resume | Resume, View summary | None | Start new onboarding | None | Not applicable | Yes | Must show attribution, stage, status, and age. |
|
||||
| Non-resumable draft summary | Direct access to completed or cancelled draft | View summary | Summary sections | None or policy-approved actions only | None | Return to onboarding landing | None | Not applicable | Yes | Must not re-enter editable wizard mode. |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Onboarding Draft**: The workspace-scoped persisted work item currently backed by `TenantOnboardingSession`, representing confirmed onboarding progress for one target managed tenant.
|
||||
- **Draft Lifecycle State**: The resumable or non-resumable state of the draft, at minimum `draft`, `completed`, or `cancelled`.
|
||||
- **Draft State Payload**: The confirmed, non-secret persisted state used to derive resume stage, provider references, verification references, bootstrap references, and review notes.
|
||||
- **Resume Context**: The operator-facing summary derived from the draft that tells the user what draft is being resumed, its current stage, and who last changed it.
|
||||
- **Draft Selection Result**: The explicit operator choice when multiple resumable drafts exist in the workspace.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-138-01 Canonical route coverage**: In focused route and Livewire coverage, 100% of in-scope draft resume flows operate on `/admin/onboarding/{draft}` once a concrete draft exists.
|
||||
- **SC-138-02 No silent ambiguity**: In focused multi-draft coverage, 100% of landing requests with multiple resumable drafts show draft selection rather than silently choosing a draft or silently resetting to blank Step 1.
|
||||
- **SC-138-03 Durable confirmed progress**: In focused refresh coverage, 100% of confirmed Step 1 and Step 2 states restore correctly after a full page reload on the draft URL.
|
||||
- **SC-138-04 Sensitive field protection**: In focused refresh coverage, 100% of secret input fields remain non-rehydrated after revisit or hard refresh.
|
||||
- **SC-138-05 Non-resumable safety**: In focused lifecycle coverage, 100% of completed or cancelled drafts stay non-editable and do not re-enter active wizard mode.
|
||||
- **SC-138-06 Attribution clarity**: In covered multi-operator flows, operators can identify who started and last updated each draft from the picker or draft header.
|
||||
- **SC-138-07 Browser-level trust**: Mandatory browser tests pass for hard refresh, revisit, and multi-draft selection on a concrete onboarding draft URL.
|
||||
170
specs/138-managed-tenant-onboarding-draft-identity/tasks.md
Normal file
170
specs/138-managed-tenant-onboarding-draft-identity/tasks.md
Normal file
@ -0,0 +1,170 @@
|
||||
# Tasks: Managed Tenant Onboarding Draft Identity & Resume Semantics
|
||||
|
||||
**Input**: Design documents from `/specs/138-managed-tenant-onboarding-draft-identity/`
|
||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/onboarding-draft-resume.openapi.yaml`
|
||||
|
||||
**Tests**: Tests are REQUIRED because this feature changes routing, wizard resume behavior, lifecycle handling, authorization, and hard-refresh behavior.
|
||||
**RBAC**: Tasks include workspace-scoped access checks, draft visibility rules, lifecycle authorization, deny-as-not-found behavior, and positive plus negative coverage.
|
||||
**UI Naming**: Tasks include aligning operator copy for draft start, resume, attribution, and non-resumable states.
|
||||
**Filament UI Action Surfaces**: Tasks include landing-state UX, multi-draft picker UX, wizard header orientation, and confirmed destructive lifecycle actions.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so each story can be implemented and validated independently once foundational work is complete.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Establish common draft-state semantics, helpers, and test fixtures.
|
||||
|
||||
- [X] T001 [P] Add onboarding draft lifecycle constants or value objects in `app/Support/Onboarding/OnboardingDraftStatus.php`, `app/Support/Onboarding/OnboardingDraftStage.php`, and related helper classes if needed
|
||||
- [X] T002 [P] Extend onboarding session factory and shared test helpers for resumable, completed, and cancelled drafts in `database/factories/TenantOnboardingSessionFactory.php` and `tests/Pest.php`
|
||||
- [X] T003 [P] Add or extend onboarding capability helpers for draft resume and cancel checks, including explicit `404` versus `403` semantics, in `app/Support/Auth/Capabilities.php`, `app/Policies`, and related authorization helpers as needed
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Build the route, persistence, and resume derivation infrastructure required before story work can start.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
|
||||
|
||||
- [X] T004 Add explicit lifecycle and attribution support to onboarding drafts while preserving workspace-scoped ownership and the nullable-tenant workflow exception in `database/migrations/*managed_tenant_onboarding_sessions*`, `app/Models/TenantOnboardingSession.php`, and `database/factories/TenantOnboardingSessionFactory.php`
|
||||
- [X] T005 [P] Add draft query helpers or scopes for resumable workspace drafts in `app/Models/TenantOnboardingSession.php`
|
||||
- [X] T006 [P] Create a draft stage derivation service or helper in `app/Services/Onboarding/OnboardingDraftStageResolver.php`
|
||||
- [X] T007 [P] Create a draft access loader or resolver for canonical route binding in `app/Services/Onboarding/OnboardingDraftResolver.php` and route binding definitions
|
||||
- [X] T008 [P] Add route definitions and route-model resolution for `/admin/onboarding` and `/admin/onboarding/{onboardingDraft}` in `routes/web.php` and related page registration code
|
||||
- [X] T009 [P] Add audit event IDs or payload conventions for draft creation, explicit resume, explicit selection, confirmed draft updates, provider connection changes, verification start and result persistence, bootstrap initiation, blocked activation override use, cancellation, and completion in `app/Enums/AuditActionId.php` or the relevant audit registry
|
||||
|
||||
**Checkpoint**: Foundation ready. User stories can now proceed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Deterministic draft identity and routing (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Give each onboarding draft a canonical URL and remove implicit resume as the primary mechanism.
|
||||
|
||||
**Independent Test**: Create or resume a draft from `/admin/onboarding`, confirm redirect to `/admin/onboarding/{draft}`, refresh, and verify the same draft still loads.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T010 [P] [US1] Add landing-route and concrete draft-route feature coverage in `tests/Feature/Onboarding/OnboardingDraftRoutingTest.php`
|
||||
- [X] T011 [P] [US1] Add draft access coverage for non-existent drafts, cross-workspace requests, non-member `404`, and in-scope member `403` behavior in `tests/Feature/Onboarding/OnboardingDraftAccessTest.php`
|
||||
- [X] T012 [P] [US1] Add unit coverage for stage derivation and canonical draft loading in `tests/Unit/Onboarding/OnboardingDraftStageResolverTest.php` and `tests/Unit/Onboarding/OnboardingDraftResolverTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T013 [US1] Refactor `ManagedTenantOnboardingWizard` to accept an explicit draft parameter and load confirmed state from the resolved draft in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||
- [X] T014 [US1] Replace heuristic-only single-session resume with landing-route redirect logic in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` or the landing controller or page wrapper
|
||||
- [X] T015 [US1] Redirect to the canonical draft URL immediately after Step 1 creates or attaches a draft in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||
- [X] T016 [US1] Persist draft attribution and current-step diagnostics on confirmed step saves in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and `app/Models/TenantOnboardingSession.php`
|
||||
|
||||
**Checkpoint**: User Story 1 is complete when every active draft has a canonical URL and refresh stays anchored to the same draft.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Clear resume and multi-draft behavior (Priority: P1)
|
||||
|
||||
**Goal**: Make landing behavior deterministic, ambiguity explicit, and resume context visible.
|
||||
|
||||
**Independent Test**: Seed multiple drafts, visit `/admin/onboarding`, confirm picker UX, resume one draft, and verify the orientation banner and derived stage.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T017 [P] [US2] Add multi-draft landing, picker, and explicit summary or detail access coverage in `tests/Feature/Onboarding/OnboardingDraftPickerTest.php`
|
||||
- [X] T018 [P] [US2] Add Livewire coverage for resume banner, picker actions, and duplicate-draft warnings in `tests/Feature/ManagedTenantOnboardingWizardTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T019 [US2] Build landing zero-, single-, and multi-draft behavior in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and related views or schema sections
|
||||
- [X] T020 [US2] Add draft picker metadata, resume action, explicit view summary or detail access, and explicit start-new action in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||
- [X] T021 [US2] Add resume orientation banner and current-stage projection in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||
- [X] T022 [US2] Add duplicate-draft warning and resume-first flow when the same target tenant already has a resumable draft in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||
|
||||
**Checkpoint**: User Story 2 is complete when ambiguity is explicit and operators always understand whether they are starting or resuming a draft.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Enterprise lifecycle, visibility, and safety (Priority: P2)
|
||||
|
||||
**Goal**: Formalize resumable versus non-resumable behavior, shared workspace visibility, attribution, and cancel authority.
|
||||
|
||||
**Independent Test**: Resume another operator’s draft, cancel a draft with authorization, and verify that completed or cancelled drafts do not reopen in edit mode.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T023 [P] [US3] Add lifecycle and non-resumable route coverage in `tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php`
|
||||
- [X] T024 [P] [US3] Add authorization coverage for shared workspace resume and cancel semantics, including member `403` versus non-member `404`, in `tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php`
|
||||
- [X] T025 [P] [US3] Add audit coverage for draft creation, explicit resume, explicit selection, confirmed draft updates, provider connection changes, verification start and result persistence, bootstrap initiation, blocked activation override use, cancellation, and completion in `tests/Feature/Audit/OnboardingDraftAuditTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T026 [US3] Add or formalize `draft`, `completed`, and `cancelled` lifecycle handling in `app/Models/TenantOnboardingSession.php` and related persistence paths
|
||||
- [X] T027 [US3] Implement non-editable handling and summary or detail access for completed and cancelled drafts in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||
- [X] T028 [US3] Add authorized cancel-draft action with confirmation in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||
- [X] T029 [US3] Surface started-by and last-updated-by attribution in picker and wizard header in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||
|
||||
**Checkpoint**: User Story 3 is complete when lifecycle state and attribution are explicit and non-resumable drafts cannot silently return to edit mode.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 - Verified refresh and resume behavior under real browser conditions (Priority: P2)
|
||||
|
||||
**Goal**: Add regression coverage for hard refresh, stale verification, and same-draft multi-tab behavior.
|
||||
|
||||
**Independent Test**: Browser tests create or resume a draft, reload the concrete draft URL, and verify confirmed state and non-rehydrated secrets remain correct.
|
||||
|
||||
### Tests for User Story 4
|
||||
|
||||
- [X] T030 [P] [US4] Add browser coverage for hard-refresh resume on the canonical draft route in `tests/Browser/OnboardingDraftRefreshTest.php`
|
||||
- [X] T031 [P] [US4] Add browser coverage for stale verification, bootstrap revisit behavior, and activation-guard persistence after refresh in `tests/Browser/OnboardingDraftVerificationResumeTest.php`
|
||||
- [X] T032 [P] [US4] Add deterministic same-draft multi-tab coverage in `tests/Feature/Onboarding/OnboardingDraftMultiTabTest.php`
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [X] T033 [US4] Preserve verification and bootstrap references across reload and stage derivation in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and `app/Services/Onboarding/OnboardingDraftStageResolver.php`
|
||||
- [X] T034 [US4] Ensure secret inputs remain transient and are never rehydrated from draft state in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||
- [X] T035 [US4] Add stale-verification projection, activation-guard persistence, and resume-safe messaging in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||
|
||||
**Checkpoint**: User Story 4 is complete when real browser refresh and revisit behavior is covered and trusted.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final naming alignment, focused validation, and cleanup.
|
||||
|
||||
- [X] T036 [P] Align onboarding draft terminology across wizard, headers, notifications, and actions in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and related UI copy sources
|
||||
- [X] T037 [P] Validate focused quickstart scenarios from `specs/138-managed-tenant-onboarding-draft-identity/quickstart.md` using the targeted onboarding and browser suites as a quality gate
|
||||
- [X] T038 Run formatting and final cleanup with `vendor/bin/sail bin pint --dirty --format agent` after implementation changes
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion and blocks all user stories.
|
||||
- **User Story 1 (Phase 3)**: Depends on Foundational completion.
|
||||
- **User Story 2 (Phase 4)**: Depends on Foundational completion and should follow immediately because it completes the landing and ambiguity model.
|
||||
- **User Story 3 (Phase 5)**: Depends on Foundational completion and benefits from the canonical route model established in US1 and US2.
|
||||
- **User Story 4 (Phase 6)**: Depends on earlier story behavior stabilizing so browser tests cover final semantics.
|
||||
- **Polish (Phase 7)**: Depends on the desired stories being complete.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- T001-T003 can run in parallel.
|
||||
- T005-T009 can run in parallel once T004 is defined.
|
||||
- Test tasks marked `[P]` can run in parallel within each user story.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First
|
||||
|
||||
Deliver **Setup + Foundational + User Story 1** first so every active onboarding draft gets a canonical URL and refresh-safe identity.
|
||||
|
||||
### Resume Trust Second
|
||||
|
||||
Deliver **User Story 2** next so multi-draft ambiguity becomes explicit and operators get reliable resume orientation.
|
||||
|
||||
### Lifecycle and Browser Hardening Last
|
||||
|
||||
Finish with **User Story 3** and **User Story 4** to formalize cancel and non-resumable behavior and to lock the experience down with browser-level regression coverage.
|
||||
1
tests/Browser/.gitkeep
Normal file
1
tests/Browser/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
102
tests/Browser/OnboardingDraftRefreshTest.php
Normal file
102
tests/Browser/OnboardingDraftRefreshTest.php
Normal file
@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
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('Browser Refresh Tenant')
|
||||
->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"]', '');
|
||||
});
|
||||
244
tests/Browser/OnboardingDraftVerificationResumeTest.php
Normal file
244
tests/Browser/OnboardingDraftVerificationResumeTest.php
Normal file
@ -0,0 +1,244 @@
|
||||
<?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('keeps stale verification warnings and the selected provider connection stable after refresh', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => '30303030-3030-3030-3030-303030303030',
|
||||
'name' => 'Stale Verification Tenant',
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
$user = User::factory()->create(['name' => 'Verification Owner']);
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$verifiedConnection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'display_name' => 'Previously verified connection',
|
||||
'is_default' => true,
|
||||
'status' => 'connected',
|
||||
]);
|
||||
|
||||
$selectedConnection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'dummy',
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'display_name' => 'Current selected connection',
|
||||
'is_default' => false,
|
||||
'status' => 'connected',
|
||||
]);
|
||||
|
||||
$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) $verifiedConnection->getKey(),
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'tenant' => $tenant,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
'current_step' => 'verify',
|
||||
'state' => [
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'tenant_name' => (string) $tenant->name,
|
||||
'provider_connection_id' => (int) $selectedConnection->getKey(),
|
||||
'verification_operation_run_id' => (int) $run->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
$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('Verify access')
|
||||
->assertSee('Status: Needs attention')
|
||||
->assertSee('The selected provider connection has changed since this verification run. Start verification again to validate the current connection.')
|
||||
->assertSee('Start verification')
|
||||
->refresh()
|
||||
->waitForText('The selected provider connection has changed since this verification run. Start verification again to validate the current connection.')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
||||
->assertSee('Status: Needs attention')
|
||||
->assertSee('Start verification')
|
||||
->click('Provider connection')
|
||||
->assertScript($visibleSelectValue, (string) $selectedConnection->getKey());
|
||||
});
|
||||
|
||||
it('preserves bootstrap revisit state and blocked activation guards after refresh', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => '40404040-4040-4040-4040-404040404040',
|
||||
'name' => 'Blocked Review Tenant',
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
$user = User::factory()->create(['name' => 'Review 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' => 'Blocked review 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::Failed->value,
|
||||
'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' => [],
|
||||
],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
$bootstrapRun = createInventorySyncOperationRun($tenant, [
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'status' => 'success',
|
||||
]);
|
||||
|
||||
$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) $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()]));
|
||||
|
||||
$activateButtonIsDisabled = <<<'JS'
|
||||
(() => {
|
||||
const button = [...document.querySelectorAll('button')].find((element) => element.textContent?.includes('Activate tenant'));
|
||||
|
||||
return button?.disabled ?? null;
|
||||
})()
|
||||
JS;
|
||||
|
||||
$openBootstrapStep = <<<'JS'
|
||||
(() => {
|
||||
const button = [...document.querySelectorAll('button')].find((element) => element.textContent?.includes('Bootstrap'));
|
||||
|
||||
if (! button) {
|
||||
return false;
|
||||
}
|
||||
|
||||
button.click();
|
||||
|
||||
return true;
|
||||
})()
|
||||
JS;
|
||||
|
||||
$page
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
||||
->assertSee('Complete')
|
||||
->assertSee('Override blocked verification')
|
||||
->assertSee('Blocked — 0/1 checks passed')
|
||||
->assertSee('Started - 1 operation run(s) started')
|
||||
->assertScript($activateButtonIsDisabled, true)
|
||||
->refresh()
|
||||
->waitForText('Override blocked verification')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
||||
->assertSee('Blocked — 0/1 checks passed')
|
||||
->assertSee('Started - 1 operation run(s) started')
|
||||
->assertScript($activateButtonIsDisabled, true)
|
||||
->assertScript($openBootstrapStep, true)
|
||||
->assertSee('Started 1 bootstrap run(s).');
|
||||
});
|
||||
302
tests/Feature/Audit/OnboardingDraftAuditTest.php
Normal file
302
tests/Feature/Audit/OnboardingDraftAuditTest.php
Normal file
@ -0,0 +1,302 @@
|
||||
<?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\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\Verification\VerificationReportWriter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Testing\TestAction;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('records start and resume audit events for onboarding drafts', 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)
|
||||
->call('identifyManagedTenant', [
|
||||
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
|
||||
'environment' => 'prod',
|
||||
'name' => 'Audit Tenant',
|
||||
]);
|
||||
|
||||
$firstDraft = TenantOnboardingSession::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('entra_tenant_id', '11111111-1111-1111-1111-111111111111')
|
||||
->firstOrFail();
|
||||
|
||||
createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
'entra_tenant_id' => '22222222-2222-2222-2222-222222222222',
|
||||
'state' => [
|
||||
'entra_tenant_id' => '22222222-2222-2222-2222-222222222222',
|
||||
'tenant_name' => 'Stability Draft',
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ManagedTenantOnboardingWizard::class)
|
||||
->call('identifyManagedTenant', [
|
||||
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
|
||||
'environment' => 'prod',
|
||||
'name' => 'Audit Tenant',
|
||||
])
|
||||
->assertRedirect(route('admin.onboarding.draft', ['onboardingDraft' => $firstDraft->getKey()]));
|
||||
|
||||
expect(AuditLog::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('action', AuditActionId::ManagedTenantOnboardingStart->value)
|
||||
->exists())->toBeTrue()
|
||||
->and(AuditLog::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('action', AuditActionId::ManagedTenantOnboardingResume->value)
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('records explicit draft selection and draft update audit entries', 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());
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
'state' => [
|
||||
'entra_tenant_id' => '33333333-3333-3333-3333-333333333333',
|
||||
'tenant_name' => 'Selectable Draft',
|
||||
],
|
||||
]);
|
||||
|
||||
createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
'state' => [
|
||||
'entra_tenant_id' => '44444444-4444-4444-4444-444444444444',
|
||||
'tenant_name' => 'Other Draft',
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ManagedTenantOnboardingWizard::class)
|
||||
->callAction(
|
||||
TestAction::make('resume_draft_'.$draft->getKey())
|
||||
->schemaComponent('draft_picker_actions_'.$draft->getKey())
|
||||
)
|
||||
->assertRedirect(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]));
|
||||
|
||||
expect(AuditLog::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('action', AuditActionId::ManagedTenantOnboardingDraftSelected->value)
|
||||
->where('resource_id', (string) $draft->getKey())
|
||||
->exists())->toBeTrue();
|
||||
|
||||
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class, [
|
||||
'onboardingDraft' => (int) $draft->getKey(),
|
||||
]);
|
||||
|
||||
$touchStep = \Closure::bind(function (string $step): void {
|
||||
$this->touchOnboardingSessionStep($step);
|
||||
}, $component->instance(), $component->instance()::class);
|
||||
|
||||
$touchStep('verify');
|
||||
|
||||
expect(AuditLog::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('action', AuditActionId::ManagedTenantOnboardingDraftUpdated->value)
|
||||
->where('resource_id', (string) $draft->getKey())
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('records provider, verification, bootstrap, and cancellation audit entries', function (): void {
|
||||
Bus::fake();
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
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' => 'Audit 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,
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class, [
|
||||
'onboardingDraft' => (int) $draft->getKey(),
|
||||
]);
|
||||
|
||||
$component->call('selectProviderConnection', (int) $connection->getKey());
|
||||
$component->call('startVerification');
|
||||
|
||||
$draft->refresh();
|
||||
|
||||
$verificationRunId = (int) ($draft->state['verification_operation_run_id'] ?? 0);
|
||||
$verificationRun = OperationRun::query()->findOrFail($verificationRunId);
|
||||
$verificationRun->update([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'context' => array_merge(is_array($verificationRun->context) ? $verificationRun->context : [], [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
]),
|
||||
]);
|
||||
|
||||
$component->call('startBootstrap', ['inventory_sync']);
|
||||
|
||||
$component
|
||||
->mountAction('cancel_onboarding_draft')
|
||||
->callMountedAction()
|
||||
->assertNotified('Onboarding draft cancelled');
|
||||
|
||||
expect(AuditLog::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('action', AuditActionId::ManagedTenantOnboardingProviderConnectionChanged->value)
|
||||
->exists())->toBeTrue()
|
||||
->and(AuditLog::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('action', AuditActionId::ManagedTenantOnboardingVerificationStart->value)
|
||||
->exists())->toBeTrue()
|
||||
->and(AuditLog::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('action', AuditActionId::ManagedTenantOnboardingVerificationPersisted->value)
|
||||
->exists())->toBeTrue()
|
||||
->and(AuditLog::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('action', AuditActionId::ManagedTenantOnboardingBootstrapStarted->value)
|
||||
->exists())->toBeTrue()
|
||||
->and(AuditLog::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('action', AuditActionId::ManagedTenantOnboardingCancelled->value)
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('records activation override audit entries when activation bypasses a blocked verification result', 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());
|
||||
|
||||
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class);
|
||||
|
||||
$component->call('identifyManagedTenant', [
|
||||
'entra_tenant_id' => '55555555-5555-5555-5555-555555555555',
|
||||
'environment' => 'prod',
|
||||
'name' => 'Override Tenant',
|
||||
]);
|
||||
|
||||
$component->call('createProviderConnection', [
|
||||
'display_name' => 'Override Connection',
|
||||
'client_id' => '00000000-0000-0000-0000-000000000000',
|
||||
'client_secret' => 'super-secret',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$component->call('startVerification');
|
||||
|
||||
$tenant = Tenant::query()->where('tenant_id', '55555555-5555-5555-5555-555555555555')->firstOrFail();
|
||||
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', 'provider.connection.check')
|
||||
->latest('id')
|
||||
->firstOrFail();
|
||||
|
||||
$run->update([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'context' => array_merge(is_array($run->context) ? $run->context : [], [
|
||||
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||
[
|
||||
'key' => 'permission_check',
|
||||
'title' => 'Graph permissions',
|
||||
'status' => 'fail',
|
||||
'severity' => 'high',
|
||||
'blocking' => true,
|
||||
'reason_code' => 'permission_denied',
|
||||
'message' => 'Missing required Graph permissions.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
|
||||
$component
|
||||
->set('data.override_blocked', true)
|
||||
->set('data.override_reason', 'Approved exception for onboarding continuity')
|
||||
->call('completeOnboarding');
|
||||
|
||||
expect(AuditLog::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('action', AuditActionId::ManagedTenantOnboardingActivationOverrideUsed->value)
|
||||
->exists())->toBeTrue()
|
||||
->and(AuditLog::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('action', AuditActionId::ManagedTenantOnboardingActivation->value)
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
@ -15,6 +15,7 @@
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Verification\VerificationReportWriter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Testing\TestAction;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('returns 404 for non-members when starting onboarding with a selected workspace', function (): void {
|
||||
@ -28,7 +29,7 @@
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('allows workspace members without onboarding capability to view the wizard but forbids execution', function (): void {
|
||||
it('forbids workspace members without onboarding capability from loading the wizard or executing actions', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
@ -42,16 +43,11 @@
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful();
|
||||
->assertForbidden();
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ManagedTenantOnboardingWizard::class)
|
||||
->call('identifyManagedTenant', [
|
||||
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
|
||||
'environment' => 'prod',
|
||||
'name' => 'Acme',
|
||||
])
|
||||
->assertStatus(403);
|
||||
->assertForbidden();
|
||||
|
||||
expect(Tenant::query()->count())->toBe(0);
|
||||
expect(TenantOnboardingSession::query()->count())->toBe(0);
|
||||
@ -167,7 +163,7 @@
|
||||
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||
[
|
||||
'key' => 'consent',
|
||||
'title' => 'Admin consent granted',
|
||||
'title' => 'Required application permissions',
|
||||
'status' => 'pass',
|
||||
'severity' => 'low',
|
||||
'blocking' => false,
|
||||
@ -194,6 +190,7 @@
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->followingRedirects()
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
->assertSee('Skipped - No bootstrap actions selected')
|
||||
@ -384,6 +381,131 @@
|
||||
$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('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)
|
||||
|
||||
146
tests/Feature/Onboarding/OnboardingDraftAccessTest.php
Normal file
146
tests/Feature/Onboarding/OnboardingDraftAccessTest.php
Normal file
@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
|
||||
it('returns 404 when the requested onboarding draft does not exist', 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(route('admin.onboarding.draft', ['onboardingDraft' => 999999]))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 404 when a draft is requested from a different selected workspace', function (): void {
|
||||
$workspaceA = Workspace::factory()->create();
|
||||
$workspaceB = Workspace::factory()->create();
|
||||
$ownerA = User::factory()->create();
|
||||
$userB = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspaceA->getKey(),
|
||||
'user_id' => (int) $ownerA->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspaceB->getKey(),
|
||||
'user_id' => (int) $userB->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
'workspace' => $workspaceA,
|
||||
'started_by' => $ownerA,
|
||||
'updated_by' => $ownerA,
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspaceB->getKey());
|
||||
|
||||
$this->actingAs($userB)
|
||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 404 when a non-member requests an onboarding draft route', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$owner = User::factory()->create();
|
||||
$nonMember = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $owner->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'started_by' => $owner,
|
||||
'updated_by' => $owner,
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$this->actingAs($nonMember)
|
||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 404 when the actor can access the workspace but lacks tenant entitlement for an identified draft', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
$owner = User::factory()->create();
|
||||
$workspaceOnlyUser = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $owner->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $workspaceOnlyUser->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'tenant' => $tenant,
|
||||
'started_by' => $owner,
|
||||
'updated_by' => $owner,
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$this->actingAs($workspaceOnlyUser)
|
||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 403 for an in-scope member with tenant entitlement but without onboarding capability', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
$readonlyUser = User::factory()->create();
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
user: $readonlyUser,
|
||||
role: 'readonly',
|
||||
workspaceRole: 'readonly',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'tenant' => $tenant,
|
||||
'started_by' => $readonlyUser,
|
||||
'updated_by' => $readonlyUser,
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$this->actingAs($readonlyUser)
|
||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||
->assertForbidden();
|
||||
});
|
||||
163
tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php
Normal file
163
tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php
Normal file
@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('allows another authorized operator to load a shared draft and see attribution', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
'name' => 'Shared Tenant',
|
||||
]);
|
||||
$creator = User::factory()->create(['name' => 'Draft Creator']);
|
||||
$manager = User::factory()->create(['name' => 'Workspace Manager']);
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
user: $creator,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
user: $manager,
|
||||
role: 'manager',
|
||||
workspaceRole: 'manager',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'tenant' => $tenant,
|
||||
'started_by' => $creator,
|
||||
'updated_by' => $manager,
|
||||
'state' => [
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'tenant_name' => (string) $tenant->name,
|
||||
],
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$this->actingAs($manager)
|
||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Onboarding draft')
|
||||
->assertSee('Draft Creator')
|
||||
->assertSee('Workspace Manager');
|
||||
});
|
||||
|
||||
it('allows a manager to cancel a shared onboarding draft', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
$creator = User::factory()->create();
|
||||
$manager = User::factory()->create();
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
user: $creator,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
user: $manager,
|
||||
role: 'manager',
|
||||
workspaceRole: 'manager',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'tenant' => $tenant,
|
||||
'started_by' => $creator,
|
||||
'updated_by' => $creator,
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
Livewire::actingAs($manager)
|
||||
->test(ManagedTenantOnboardingWizard::class, [
|
||||
'onboardingDraft' => (int) $draft->getKey(),
|
||||
])
|
||||
->mountAction('cancel_onboarding_draft')
|
||||
->callMountedAction()
|
||||
->assertNotified('Onboarding draft cancelled');
|
||||
|
||||
expect($draft->fresh()->isCancelled())->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns 404 for non-members when requesting a shared onboarding draft', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
$creator = User::factory()->create();
|
||||
$nonMember = User::factory()->create();
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
user: $creator,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'tenant' => $tenant,
|
||||
'started_by' => $creator,
|
||||
'updated_by' => $creator,
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$this->actingAs($nonMember)
|
||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 403 for readonly members even when they have tenant access', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
$readonly = User::factory()->create();
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
user: $readonly,
|
||||
role: 'readonly',
|
||||
workspaceRole: 'readonly',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'tenant' => $tenant,
|
||||
'started_by' => $readonly,
|
||||
'updated_by' => $readonly,
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$this->actingAs($readonly)
|
||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||
->assertForbidden();
|
||||
});
|
||||
158
tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php
Normal file
158
tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php
Normal file
@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('shows a safe non-editable summary for completed drafts', 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());
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
'status' => 'completed',
|
||||
'state' => [
|
||||
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
|
||||
'tenant_name' => 'Completed Contoso',
|
||||
'environment' => 'prod',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||
->assertSuccessful()
|
||||
->assertSee('This onboarding draft is Completed.')
|
||||
->assertSee('Completed and cancelled drafts remain viewable, but they cannot return to editable wizard mode.')
|
||||
->assertSee('Return to onboarding')
|
||||
->assertDontSee('Cancel draft');
|
||||
});
|
||||
|
||||
it('shows a safe non-editable summary for cancelled drafts', 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());
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
'status' => 'cancelled',
|
||||
'state' => [
|
||||
'entra_tenant_id' => '22222222-2222-2222-2222-222222222222',
|
||||
'tenant_name' => 'Cancelled Contoso',
|
||||
'environment' => 'prod',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||
->assertSuccessful()
|
||||
->assertSee('This onboarding draft is Cancelled.')
|
||||
->assertSee('Return to onboarding')
|
||||
->assertDontSee('Cancel draft');
|
||||
});
|
||||
|
||||
it('cancels a resumable onboarding draft through the confirmed header action', 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());
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
'state' => [
|
||||
'entra_tenant_id' => '33333333-3333-3333-3333-333333333333',
|
||||
'tenant_name' => 'Draft To Cancel',
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ManagedTenantOnboardingWizard::class, [
|
||||
'onboardingDraft' => (int) $draft->getKey(),
|
||||
])
|
||||
->mountAction('cancel_onboarding_draft')
|
||||
->callMountedAction()
|
||||
->assertNotified('Onboarding draft cancelled');
|
||||
|
||||
$draft->refresh();
|
||||
|
||||
expect($draft->cancelled_at)->not->toBeNull()
|
||||
->and($draft->isResumable())->toBeFalse()
|
||||
->and($draft->current_step)->toBe('cancelled');
|
||||
|
||||
expect(AuditLog::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('action', AuditActionId::ManagedTenantOnboardingCancelled->value)
|
||||
->where('resource_id', (string) $draft->getKey())
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('keeps cancelled drafts out of the landing redirect and picker flow', 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());
|
||||
|
||||
$activeDraft = createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
'state' => [
|
||||
'entra_tenant_id' => '44444444-4444-4444-4444-444444444444',
|
||||
'tenant_name' => 'Active Draft',
|
||||
],
|
||||
]);
|
||||
|
||||
createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
'status' => 'cancelled',
|
||||
'state' => [
|
||||
'entra_tenant_id' => '55555555-5555-5555-5555-555555555555',
|
||||
'tenant_name' => 'Cancelled Draft',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.onboarding'))
|
||||
->assertRedirect(route('admin.onboarding.draft', ['onboardingDraft' => $activeDraft->getKey()]));
|
||||
});
|
||||
77
tests/Feature/Onboarding/OnboardingDraftMultiTabTest.php
Normal file
77
tests/Feature/Onboarding/OnboardingDraftMultiTabTest.php
Normal file
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('keeps same-draft writes deterministic when the draft is open in two tabs', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$firstConnection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'display_name' => 'First Connection',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$secondConnection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'dummy',
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'display_name' => 'Second Connection',
|
||||
'is_default' => false,
|
||||
]);
|
||||
|
||||
$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,
|
||||
'provider_connection_id' => (int) $firstConnection->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$firstTab = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class, [
|
||||
'onboardingDraft' => (int) $draft->getKey(),
|
||||
]);
|
||||
|
||||
$secondTab = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class, [
|
||||
'onboardingDraft' => (int) $draft->getKey(),
|
||||
]);
|
||||
|
||||
$firstTab->call('selectProviderConnection', (int) $firstConnection->getKey());
|
||||
$secondTab->call('selectProviderConnection', (int) $secondConnection->getKey());
|
||||
|
||||
$draft->refresh();
|
||||
|
||||
expect($draft->state['provider_connection_id'] ?? null)->toBe((int) $secondConnection->getKey())
|
||||
->and($draft->updated_by_user_id)->toBe((int) $user->getKey());
|
||||
});
|
||||
126
tests/Feature/Onboarding/OnboardingDraftPickerTest.php
Normal file
126
tests/Feature/Onboarding/OnboardingDraftPickerTest.php
Normal file
@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
|
||||
it('shows a draft picker with resumable draft metadata when multiple drafts exist', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$startedBy = User::factory()->create(['name' => 'Primary Owner']);
|
||||
$updatedBy = User::factory()->create(['name' => 'Second Operator']);
|
||||
|
||||
foreach ([$user, $startedBy, $updatedBy] as $member) {
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $member->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
}
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'started_by' => $startedBy,
|
||||
'updated_by' => $updatedBy,
|
||||
'state' => [
|
||||
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
|
||||
'tenant_name' => 'Contoso',
|
||||
'environment' => 'prod',
|
||||
'primary_domain' => 'contoso.example',
|
||||
],
|
||||
]);
|
||||
|
||||
createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'started_by' => $updatedBy,
|
||||
'updated_by' => $startedBy,
|
||||
'current_step' => 'connection',
|
||||
'state' => [
|
||||
'entra_tenant_id' => '22222222-2222-2222-2222-222222222222',
|
||||
'tenant_name' => 'Fabrikam',
|
||||
'environment' => 'staging',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.onboarding'))
|
||||
->assertSuccessful()
|
||||
->assertSee('Multiple onboarding drafts are available.')
|
||||
->assertSee('Contoso')
|
||||
->assertSee('Fabrikam')
|
||||
->assertSee('Current stage')
|
||||
->assertSee('Started by')
|
||||
->assertSee('Last updated by')
|
||||
->assertSee('Primary Owner')
|
||||
->assertSee('Second Operator')
|
||||
->assertSee('Resume onboarding draft')
|
||||
->assertSee('View summary');
|
||||
});
|
||||
|
||||
it('excludes completed and cancelled drafts from the landing 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());
|
||||
|
||||
createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
'state' => [
|
||||
'entra_tenant_id' => '33333333-3333-3333-3333-333333333333',
|
||||
'tenant_name' => 'Visible Draft A',
|
||||
],
|
||||
]);
|
||||
|
||||
createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
'state' => [
|
||||
'entra_tenant_id' => '44444444-4444-4444-4444-444444444444',
|
||||
'tenant_name' => 'Visible Draft B',
|
||||
],
|
||||
]);
|
||||
|
||||
createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
'status' => 'completed',
|
||||
'state' => [
|
||||
'entra_tenant_id' => '55555555-5555-5555-5555-555555555555',
|
||||
'tenant_name' => 'Completed Draft',
|
||||
],
|
||||
]);
|
||||
|
||||
createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
'status' => 'cancelled',
|
||||
'state' => [
|
||||
'entra_tenant_id' => '66666666-6666-6666-6666-666666666666',
|
||||
'tenant_name' => 'Cancelled Draft',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.onboarding'))
|
||||
->assertSuccessful()
|
||||
->assertSee('Visible Draft A')
|
||||
->assertSee('Visible Draft B')
|
||||
->assertDontSee('Completed Draft')
|
||||
->assertDontSee('Cancelled Draft');
|
||||
});
|
||||
129
tests/Feature/Onboarding/OnboardingDraftRoutingTest.php
Normal file
129
tests/Feature/Onboarding/OnboardingDraftRoutingTest.php
Normal file
@ -0,0 +1,129 @@
|
||||
<?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('shows the onboarding start state when no resumable drafts exist', 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(route('admin.onboarding'))
|
||||
->assertSuccessful()
|
||||
->assertSee('Create or resume a managed tenant in this workspace.')
|
||||
->assertDontSee('Multiple onboarding drafts are available.');
|
||||
});
|
||||
|
||||
it('redirects the landing route to the only resumable draft', 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());
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
'state' => [
|
||||
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
|
||||
'tenant_name' => 'Contoso',
|
||||
'environment' => 'prod',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.onboarding'))
|
||||
->assertRedirect(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]));
|
||||
});
|
||||
|
||||
it('loads a concrete draft route with confirmed persisted state', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => '22222222-2222-2222-2222-222222222222',
|
||||
'name' => 'Contoso GmbH',
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$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',
|
||||
'primary_domain' => 'contoso.example',
|
||||
'notes' => 'Confirmed draft state',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Onboarding draft')
|
||||
->assertSee('Contoso GmbH')
|
||||
->assertSee('22222222-2222-2222-2222-222222222222')
|
||||
->assertSee('Started by')
|
||||
->assertSee($user->name);
|
||||
});
|
||||
|
||||
it('redirects to the canonical draft route immediately after step one identifies a 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());
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ManagedTenantOnboardingWizard::class)
|
||||
->call('identifyManagedTenant', [
|
||||
'entra_tenant_id' => '33333333-3333-3333-3333-333333333333',
|
||||
'environment' => 'prod',
|
||||
'name' => 'Canonical Draft Tenant',
|
||||
])
|
||||
->assertRedirect(route('admin.onboarding.draft', [
|
||||
'onboardingDraft' => TenantOnboardingSession::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('entra_tenant_id', '33333333-3333-3333-3333-333333333333')
|
||||
->value('id'),
|
||||
]));
|
||||
});
|
||||
@ -20,7 +20,7 @@
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('allows workspace members without onboarding capability to view the page but denies action attempts with 403', function (): void {
|
||||
it('forbids workspace members without onboarding capability from loading the page or executing actions', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
@ -34,14 +34,9 @@
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful();
|
||||
->assertForbidden();
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ManagedTenantOnboardingWizard::class)
|
||||
->call('identifyManagedTenant', [
|
||||
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
|
||||
'environment' => 'prod',
|
||||
'name' => 'Acme',
|
||||
])
|
||||
->assertStatus(403);
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
@ -35,6 +35,10 @@
|
||||
'status' => 'onboarding',
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$checks = [
|
||||
[
|
||||
'key' => 'provider.connection.check',
|
||||
@ -49,7 +53,7 @@
|
||||
],
|
||||
[
|
||||
'key' => 'permissions.admin_consent',
|
||||
'title' => 'Admin consent granted',
|
||||
'title' => 'Required application permissions',
|
||||
'status' => 'fail',
|
||||
'severity' => 'critical',
|
||||
'blocking' => true,
|
||||
@ -108,10 +112,11 @@
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->followingRedirects()
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
->assertSee('Technical details')
|
||||
->assertSee('Admin consent granted')
|
||||
->assertSee('Required application permissions')
|
||||
->assertSee('Open required permissions')
|
||||
->assertSee('Issues')
|
||||
->assertSee($entraTenantId);
|
||||
@ -138,6 +143,10 @@
|
||||
'status' => 'onboarding',
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$verificationReport = VerificationReportWriter::build('provider.connection.check', []);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
@ -155,7 +164,7 @@
|
||||
],
|
||||
]);
|
||||
|
||||
TenantOnboardingSession::query()->create([
|
||||
$session = TenantOnboardingSession::query()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
@ -169,7 +178,9 @@
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(ManagedTenantOnboardingWizard::class)
|
||||
Livewire::test(ManagedTenantOnboardingWizard::class, [
|
||||
'onboardingDraft' => (int) $session->getKey(),
|
||||
])
|
||||
->mountAction('wizardVerificationTechnicalDetails')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
@ -179,6 +179,10 @@
|
||||
'status' => 'onboarding',
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
@ -231,6 +235,7 @@
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->followingRedirects()
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
->assertSee('Status: Blocked')
|
||||
@ -318,6 +323,10 @@
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$microsoftConnection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
@ -350,7 +359,7 @@
|
||||
],
|
||||
]);
|
||||
|
||||
TenantOnboardingSession::query()->create([
|
||||
$session = TenantOnboardingSession::query()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
@ -363,7 +372,9 @@
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class);
|
||||
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class, [
|
||||
'onboardingDraft' => (int) $session->getKey(),
|
||||
]);
|
||||
|
||||
expect($component->instance()->verificationSucceeded())->toBeFalse();
|
||||
});
|
||||
|
||||
@ -30,6 +30,10 @@
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
@ -49,6 +53,7 @@
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->followingRedirects()
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
->assertSee('Start verification')
|
||||
@ -76,6 +81,7 @@
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->followingRedirects()
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
->assertSee('Refresh')
|
||||
@ -99,6 +105,10 @@
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
@ -205,6 +215,7 @@
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->followingRedirects()
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
->assertSee('Read-only')
|
||||
|
||||
@ -134,6 +134,10 @@
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
@ -181,6 +185,7 @@
|
||||
|
||||
assertNoOutboundHttp(function () use ($user): void {
|
||||
$this->actingAs($user)
|
||||
->followingRedirects()
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
->assertSee('Onboarding check');
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
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;
|
||||
@ -33,6 +34,10 @@
|
||||
->use(RefreshDatabase::class)
|
||||
->in('Feature');
|
||||
|
||||
pest()->extend(Tests\TestCase::class)
|
||||
->use(RefreshDatabase::class)
|
||||
->in('Browser');
|
||||
|
||||
pest()->extend(Tests\TestCase::class)
|
||||
->in('Unit');
|
||||
|
||||
@ -431,6 +436,57 @@ function ensureDefaultProviderConnection(
|
||||
return $connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
function createOnboardingDraft(array $attributes = []): TenantOnboardingSession
|
||||
{
|
||||
$workspace = $attributes['workspace'] ?? Workspace::factory()->create();
|
||||
$tenant = $attributes['tenant'] ?? null;
|
||||
$startedBy = $attributes['started_by'] ?? User::factory()->create();
|
||||
$updatedBy = $attributes['updated_by'] ?? $startedBy;
|
||||
|
||||
foreach ([$startedBy, $updatedBy] as $member) {
|
||||
if (! $member instanceof User) {
|
||||
continue;
|
||||
}
|
||||
|
||||
WorkspaceMembership::query()->firstOrCreate([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $member->getKey(),
|
||||
], [
|
||||
'role' => 'owner',
|
||||
]);
|
||||
}
|
||||
|
||||
$factory = TenantOnboardingSession::factory()
|
||||
->forWorkspace($workspace)
|
||||
->startedBy($startedBy)
|
||||
->updatedBy($updatedBy);
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$factory = $factory->forTenant($tenant);
|
||||
}
|
||||
|
||||
if (($attributes['status'] ?? null) === 'completed') {
|
||||
$factory = $factory->completed();
|
||||
}
|
||||
|
||||
if (($attributes['status'] ?? null) === 'cancelled') {
|
||||
$factory = $factory->cancelled();
|
||||
}
|
||||
|
||||
unset(
|
||||
$attributes['workspace'],
|
||||
$attributes['tenant'],
|
||||
$attributes['started_by'],
|
||||
$attributes['updated_by'],
|
||||
$attributes['status'],
|
||||
);
|
||||
|
||||
return $factory->create($attributes);
|
||||
}
|
||||
|
||||
function ensureDefaultPlatformProviderConnection(Tenant $tenant, string $provider = 'microsoft'): ProviderConnection
|
||||
{
|
||||
return ensureDefaultProviderConnection($tenant, $provider, ProviderConnectionType::Platform->value);
|
||||
|
||||
160
tests/Unit/Onboarding/OnboardingDraftResolverTest.php
Normal file
160
tests/Unit/Onboarding/OnboardingDraftResolverTest.php
Normal file
@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Onboarding\OnboardingDraftResolver;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('resolves an accessible draft and eager loads its related models', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'tenant' => $tenant,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$resolved = app(OnboardingDraftResolver::class)->resolve($draft->getKey(), $user, $workspace);
|
||||
|
||||
expect((int) $resolved->getKey())->toBe((int) $draft->getKey())
|
||||
->and($resolved->relationLoaded('tenant'))->toBeTrue()
|
||||
->and($resolved->relationLoaded('startedByUser'))->toBeTrue()
|
||||
->and($resolved->relationLoaded('updatedByUser'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('throws not found when resolving a missing draft', 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());
|
||||
|
||||
expect(fn () => app(OnboardingDraftResolver::class)->resolve(999999, $user, $workspace))
|
||||
->toThrow(NotFoundHttpException::class);
|
||||
});
|
||||
|
||||
it('throws not found when resolving a draft from another workspace', function (): void {
|
||||
$workspaceA = Workspace::factory()->create();
|
||||
$workspaceB = Workspace::factory()->create();
|
||||
$ownerA = User::factory()->create();
|
||||
$userB = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspaceA->getKey(),
|
||||
'user_id' => (int) $ownerA->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspaceB->getKey(),
|
||||
'user_id' => (int) $userB->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
'workspace' => $workspaceA,
|
||||
'started_by' => $ownerA,
|
||||
'updated_by' => $ownerA,
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspaceB->getKey());
|
||||
|
||||
expect(fn () => app(OnboardingDraftResolver::class)->resolve($draft->getKey(), $userB, $workspaceB))
|
||||
->toThrow(NotFoundHttpException::class);
|
||||
});
|
||||
|
||||
it('throws authorization exceptions for in-scope members without onboarding capability', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
$readonlyUser = User::factory()->create();
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
user: $readonlyUser,
|
||||
role: 'readonly',
|
||||
workspaceRole: 'readonly',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'tenant' => $tenant,
|
||||
'started_by' => $readonlyUser,
|
||||
'updated_by' => $readonlyUser,
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
expect(fn () => app(OnboardingDraftResolver::class)->resolve($draft->getKey(), $readonlyUser, $workspace))
|
||||
->toThrow(AuthorizationException::class);
|
||||
});
|
||||
|
||||
it('returns only authorized resumable drafts for the selected workspace', 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());
|
||||
|
||||
$activeDraft = createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
]);
|
||||
|
||||
createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
'status' => 'completed',
|
||||
]);
|
||||
|
||||
createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
'status' => 'cancelled',
|
||||
]);
|
||||
|
||||
$drafts = app(OnboardingDraftResolver::class)->resumableDraftsFor($user, $workspace);
|
||||
|
||||
expect($drafts->modelKeys())->toBe([(int) $activeDraft->getKey()]);
|
||||
});
|
||||
120
tests/Unit/Onboarding/OnboardingDraftStageResolverTest.php
Normal file
120
tests/Unit/Onboarding/OnboardingDraftStageResolverTest.php
Normal file
@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\Onboarding\OnboardingDraftStageResolver;
|
||||
use App\Support\Onboarding\OnboardingDraftStage;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('derives the identify stage when no confirmed tenant identity exists', function (): void {
|
||||
$draft = createOnboardingDraft([
|
||||
'entra_tenant_id' => '',
|
||||
'tenant_id' => null,
|
||||
'state' => [],
|
||||
]);
|
||||
|
||||
expect(app(OnboardingDraftStageResolver::class)->resolve($draft))
|
||||
->toBe(OnboardingDraftStage::Identify);
|
||||
});
|
||||
|
||||
it('derives the connect provider stage once tenant identity exists', function (): void {
|
||||
$draft = createOnboardingDraft([
|
||||
'state' => [
|
||||
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
|
||||
'tenant_name' => 'Contoso',
|
||||
],
|
||||
]);
|
||||
|
||||
expect(app(OnboardingDraftStageResolver::class)->resolve($draft))
|
||||
->toBe(OnboardingDraftStage::ConnectProvider);
|
||||
});
|
||||
|
||||
it('derives the verify access stage when a provider connection is selected but verification is incomplete', function (): void {
|
||||
$draft = createOnboardingDraft([
|
||||
'current_step' => 'connection',
|
||||
'state' => [
|
||||
'entra_tenant_id' => '22222222-2222-2222-2222-222222222222',
|
||||
'tenant_name' => 'Contoso',
|
||||
'provider_connection_id' => 42,
|
||||
],
|
||||
]);
|
||||
|
||||
expect(app(OnboardingDraftStageResolver::class)->resolve($draft))
|
||||
->toBe(OnboardingDraftStage::VerifyAccess);
|
||||
});
|
||||
|
||||
it('derives the bootstrap stage when verification completed for the selected provider connection', function (): void {
|
||||
$draft = createOnboardingDraft([
|
||||
'current_step' => 'verify',
|
||||
'state' => [
|
||||
'entra_tenant_id' => '33333333-3333-3333-3333-333333333333',
|
||||
'tenant_name' => 'Contoso',
|
||||
'provider_connection_id' => 84,
|
||||
],
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $draft->workspace_id,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'context' => [
|
||||
'provider_connection_id' => 84,
|
||||
],
|
||||
]);
|
||||
|
||||
$draft->forceFill([
|
||||
'state' => array_merge($draft->state ?? [], [
|
||||
'verification_operation_run_id' => (int) $run->getKey(),
|
||||
]),
|
||||
])->save();
|
||||
|
||||
expect(app(OnboardingDraftStageResolver::class)->resolve($draft->fresh()))
|
||||
->toBe(OnboardingDraftStage::Bootstrap);
|
||||
});
|
||||
|
||||
it('derives the review stage when bootstrap choices were already confirmed', function (): void {
|
||||
$draft = createOnboardingDraft([
|
||||
'current_step' => 'bootstrap',
|
||||
'state' => [
|
||||
'entra_tenant_id' => '44444444-4444-4444-4444-444444444444',
|
||||
'tenant_name' => 'Contoso',
|
||||
'provider_connection_id' => 126,
|
||||
'bootstrap_operation_types' => ['inventory_sync'],
|
||||
],
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $draft->workspace_id,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'context' => [
|
||||
'provider_connection_id' => 126,
|
||||
],
|
||||
]);
|
||||
|
||||
$draft->forceFill([
|
||||
'state' => array_merge($draft->state ?? [], [
|
||||
'verification_operation_run_id' => (int) $run->getKey(),
|
||||
]),
|
||||
])->save();
|
||||
|
||||
expect(app(OnboardingDraftStageResolver::class)->resolve($draft->fresh()))
|
||||
->toBe(OnboardingDraftStage::Review);
|
||||
});
|
||||
|
||||
it('derives terminal stages from draft lifecycle state', function (string $status, OnboardingDraftStage $expectedStage): void {
|
||||
$draft = createOnboardingDraft([
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
expect(app(OnboardingDraftStageResolver::class)->resolve($draft))
|
||||
->toBe($expectedStage);
|
||||
})->with([
|
||||
['completed', OnboardingDraftStage::Completed],
|
||||
['cancelled', OnboardingDraftStage::Cancelled],
|
||||
]);
|
||||
@ -23,7 +23,7 @@
|
||||
'checks' => [
|
||||
[
|
||||
'key' => 'permissions.admin_consent',
|
||||
'title' => 'Admin consent granted',
|
||||
'title' => 'Required application permissions',
|
||||
'status' => 'warn',
|
||||
'severity' => 'medium',
|
||||
'blocking' => false,
|
||||
@ -42,7 +42,9 @@
|
||||
$sanitized = VerificationReportSanitizer::sanitizeReport($report);
|
||||
|
||||
$evidence = $sanitized['checks'][0]['evidence'] ?? null;
|
||||
$title = $sanitized['checks'][0]['title'] ?? null;
|
||||
|
||||
expect($title)->toBe('Required application permissions');
|
||||
expect($evidence)->toBeArray();
|
||||
expect($evidence)->toContain(['kind' => 'app_id', 'value' => '00000000-0000-0000-0000-000000000000']);
|
||||
expect($evidence)->toContain(['kind' => 'observed_permissions_count', 'value' => 0]);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user