feat: unified managed tenant onboarding wizard (#88)

Implements workspace-scoped managed tenant onboarding wizard (Filament v5 / Livewire v4) with strict RBAC (404/403 semantics), resumable sessions, provider connection selection/creation, verification OperationRun, and optional bootstrap. Removes legacy onboarding entrypoints and adds Pest coverage + spec artifacts (073).

## Summary
<!-- Kurz: Was ändert sich und warum? -->

## Spec-Driven Development (SDD)
- [ ] Es gibt eine Spec unter `specs/<NNN>-<feature>/`
- [ ] Enthaltene Dateien: `plan.md`, `tasks.md`, `spec.md`
- [ ] Spec beschreibt Verhalten/Acceptance Criteria (nicht nur Implementation)
- [ ] Wenn sich Anforderungen während der Umsetzung geändert haben: Spec/Plan/Tasks wurden aktualisiert

## Implementation
- [ ] Implementierung entspricht der Spec
- [ ] Edge cases / Fehlerfälle berücksichtigt
- [ ] Keine unbeabsichtigten Änderungen außerhalb des Scopes

## Tests
- [ ] Tests ergänzt/aktualisiert (Pest/PHPUnit)
- [ ] Relevante Tests lokal ausgeführt (`./vendor/bin/sail artisan test` oder `php artisan test`)

## Migration / Config / Ops (falls relevant)
- [ ] Migration(en) enthalten und getestet
- [ ] Rollback bedacht (rückwärts kompatibel, sichere Migration)
- [ ] Neue Env Vars dokumentiert (`.env.example` / Doku)
- [ ] Queue/cron/storage Auswirkungen geprüft

## UI (Filament/Livewire) (falls relevant)
- [ ] UI-Flows geprüft
- [ ] Screenshots/Notizen hinzugefügt

## Notes
<!-- Links, Screenshots, Follow-ups, offene Punkte -->

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.fritz.box>
Reviewed-on: #88
This commit is contained in:
ahmido 2026-02-03 17:30:15 +00:00
parent 5f9e6fb04a
commit b6343d5c3a
83 changed files with 3894 additions and 597 deletions

View File

@ -14,6 +14,8 @@ ## Active Technologies
- PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 (058-tenant-ui-polish)
- PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 (067-rbac-troubleshooting)
- PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting)
- PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x (073-unified-managed-tenant-onboarding-wizard)
- PostgreSQL (Sail) + SQLite in tests where applicable (073-unified-managed-tenant-onboarding-wizard)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -33,9 +35,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 073-unified-managed-tenant-onboarding-wizard: Added PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x
- 067-rbac-troubleshooting: Added PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4
- 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1
- 058-tenant-ui-polish: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
<!-- MANUAL ADDITIONS START -->

View File

@ -4,7 +4,6 @@
namespace App\Filament\Pages;
use App\Filament\Pages\Tenancy\RegisterTenant as RegisterTenantPage;
use App\Models\Tenant;
use App\Models\User;
use App\Models\UserTenantPreference;
@ -73,11 +72,6 @@ public function selectTenant(int $tenantId): void
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
}
public function canRegisterTenant(): bool
{
return RegisterTenantPage::canView();
}
private function persistLastTenant(User $user, Tenant $tenant): void
{
if (Schema::hasColumn('users', 'last_tenant_id')) {

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,6 @@
namespace App\Filament\Pages\Workspaces;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\Tenancy\RegisterTenant as RegisterTenantPage;
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\User;
@ -48,11 +47,6 @@ public function getTenants(): Collection
->get();
}
public function canRegisterTenant(): bool
{
return RegisterTenantPage::canView();
}
public function goToChooseTenant(): void
{
$this->redirect(ChooseTenant::getUrl());

View File

@ -17,6 +17,8 @@ class WorkspaceResource extends Resource
{
protected static ?string $model = Workspace::class;
protected static bool $isDiscovered = false;
protected static bool $isScopedToTenant = false;
protected static ?string $recordTitleAttribute = 'name';

View File

@ -51,7 +51,7 @@ public function __invoke(Request $request): RedirectResponse
$tenantCount = (int) $tenantsQuery->count();
if ($tenantCount === 0) {
return redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]);
return redirect()->route('admin.workspace.managed-tenants.onboarding', ['workspace' => $workspace->slug ?? $workspace->getKey()]);
}
if ($tenantCount === 1) {

View File

@ -0,0 +1,54 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TenantOnboardingSession extends Model
{
/** @use HasFactory<\Database\Factories\TenantOnboardingSessionFactory> */
use HasFactory;
protected $table = 'managed_tenant_onboarding_sessions';
protected $guarded = [];
protected $casts = [
'state' => 'array',
'completed_at' => 'datetime',
];
/**
* @return BelongsTo<Workspace, $this>
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* @return BelongsTo<User, $this>
*/
public function startedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'started_by_user_id');
}
/**
* @return BelongsTo<User, $this>
*/
public function updatedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by_user_id');
}
}

View File

@ -6,8 +6,10 @@
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Policies\ProviderConnectionPolicy;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Auth\PlatformCapabilities;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
@ -23,19 +25,36 @@ public function boot(): void
{
$this->registerPolicies();
$resolver = app(CapabilityResolver::class);
$tenantResolver = app(CapabilityResolver::class);
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
$defineTenantCapability = function (string $capability) use ($resolver): void {
Gate::define($capability, function (User $user, ?Tenant $tenant = null) use ($resolver, $capability): bool {
$defineTenantCapability = function (string $capability) use ($tenantResolver): void {
Gate::define($capability, function (User $user, ?Tenant $tenant = null) use ($tenantResolver, $capability): bool {
if (! $tenant instanceof Tenant) {
return false;
}
return $resolver->can($user, $tenant, $capability);
return $tenantResolver->can($user, $tenant, $capability);
});
};
$defineWorkspaceCapability = function (string $capability) use ($workspaceResolver): void {
Gate::define($capability, function (User $user, ?Workspace $workspace = null) use ($workspaceResolver, $capability): bool {
if (! $workspace instanceof Workspace) {
return false;
}
return $workspaceResolver->can($user, $workspace, $capability);
});
};
foreach (Capabilities::all() as $capability) {
if (str_starts_with($capability, 'workspace')) {
$defineWorkspaceCapability($capability);
continue;
}
$defineTenantCapability($capability);
}

View File

@ -6,8 +6,8 @@
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\NoAccess;
use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use App\Models\Tenant;
use App\Support\Middleware\DenyNonMemberTenantAccess;
use Filament\Facades\Filament;
@ -42,25 +42,20 @@ public function panel(Panel $panel): Panel
ChooseWorkspace::registerRoutes($panel);
ChooseTenant::registerRoutes($panel);
NoAccess::registerRoutes($panel);
WorkspaceResource::registerRoutes($panel);
})
->tenant(Tenant::class, slugAttribute: 'external_id')
->tenantRoutePrefix('t')
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
->searchableTenantMenu()
->tenantRegistration(RegisterTenant::class)
->colors([
'primary' => Color::Amber,
])
->navigationItems([
NavigationItem::make('Workspaces')
->url(function (): string {
$tenant = Filament::getTenant();
if ($tenant instanceof Tenant) {
return route('filament.admin.resources.workspaces.index', ['tenant' => $tenant->external_id]);
}
return ChooseWorkspace::getUrl();
return route('filament.admin.resources.workspaces.index');
})
->icon('heroicon-o-squares-2x2')
->group('Settings')

View File

@ -7,6 +7,7 @@
use App\Models\AuditLog;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Audit\AuditContextSanitizer;
use Carbon\CarbonImmutable;
class WorkspaceAuditLogger
@ -26,6 +27,10 @@ public function log(
$metadata = $context['metadata'] ?? [];
unset($context['metadata']);
$metadata = is_array($metadata) ? $metadata : [];
$sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context);
return AuditLog::create([
'tenant_id' => null,
'workspace_id' => (int) $workspace->getKey(),
@ -36,7 +41,7 @@ public function log(
'resource_type' => $resourceType,
'resource_id' => $resourceId,
'status' => $status,
'metadata' => $metadata + $context,
'metadata' => $sanitizedMetadata,
'recorded_at' => CarbonImmutable::now(),
]);
}

View File

@ -23,12 +23,14 @@ class WorkspaceRoleCapabilityMap
Capabilities::WORKSPACE_ARCHIVE,
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD,
],
WorkspaceRole::Manager->value => [
Capabilities::WORKSPACE_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD,
],
WorkspaceRole::Operator->value => [

View File

@ -4,6 +4,7 @@
use App\Models\AuditLog;
use App\Models\Tenant;
use App\Support\Audit\AuditContextSanitizer;
use Carbon\CarbonImmutable;
class AuditLogger
@ -22,6 +23,10 @@ public function log(
$metadata = $context['metadata'] ?? [];
unset($context['metadata']);
$metadata = is_array($metadata) ? $metadata : [];
$sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context);
return AuditLog::create([
'tenant_id' => $tenant->id,
'actor_id' => $actorId,
@ -31,7 +36,7 @@ public function log(
'resource_type' => $resourceType,
'resource_id' => $resourceId,
'status' => $status,
'metadata' => $metadata + $context,
'metadata' => $sanitizedMetadata,
'recorded_at' => CarbonImmutable::now(),
]);
}

View File

@ -22,4 +22,9 @@ enum AuditActionId: string
// Diagnostics / repair actions.
case TenantMembershipDuplicatesMerged = 'tenant_membership.duplicates_merged';
// Managed tenant onboarding wizard.
case ManagedTenantOnboardingStart = 'managed_tenant_onboarding.start';
case ManagedTenantOnboardingResume = 'managed_tenant_onboarding.resume';
case ManagedTenantOnboardingVerificationStart = 'managed_tenant_onboarding.verification_start';
}

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Support\Audit;
final class AuditContextSanitizer
{
private const REDACTED = '[REDACTED]';
public static function sanitize(mixed $value): mixed
{
if (is_array($value)) {
$sanitized = [];
foreach ($value as $key => $item) {
if (is_string($key) && self::shouldRedactKey($key)) {
$sanitized[$key] = self::REDACTED;
continue;
}
$sanitized[$key] = self::sanitize($item);
}
return $sanitized;
}
if (is_string($value)) {
return self::sanitizeString($value);
}
return $value;
}
private static function shouldRedactKey(string $key): bool
{
$key = strtolower(trim($key));
return str_contains($key, 'token')
|| str_contains($key, 'secret')
|| str_contains($key, 'password')
|| str_contains($key, 'authorization')
|| str_contains($key, 'private_key')
|| str_contains($key, 'client_secret');
}
private static function sanitizeString(string $value): string
{
$candidate = trim($value);
if ($candidate === '') {
return $value;
}
if (preg_match('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', $candidate)) {
return self::REDACTED;
}
if (preg_match('/\b[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\b/', $candidate)) {
return self::REDACTED;
}
return $value;
}
}

View File

@ -27,6 +27,9 @@ class Capabilities
public const WORKSPACE_MEMBERSHIP_MANAGE = 'workspace_membership.manage';
// Managed tenant onboarding
public const WORKSPACE_MANAGED_TENANT_ONBOARD = 'workspace_managed_tenant.onboard';
// Tenants
public const TENANT_VIEW = 'tenant.view';

View File

@ -13,6 +13,7 @@ public function spec(mixed $value): BadgeSpec
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'pending' => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
'active' => new BadgeSpec('Active', 'success', 'heroicon-m-check-circle'),
'inactive' => new BadgeSpec('Inactive', 'gray', 'heroicon-m-minus-circle'),
'archived' => new BadgeSpec('Archived', 'gray', 'heroicon-m-minus-circle'),

View File

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (Schema::hasTable('managed_tenant_onboarding_sessions')) {
return;
}
Schema::create('managed_tenant_onboarding_sessions', function (Blueprint $table) {
$table->id();
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('current_step')->nullable();
$table->json('state')->nullable();
$table->foreignId('started_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('updated_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
$table->unique(['workspace_id', 'tenant_id']);
$table->index(['tenant_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('managed_tenant_onboarding_sessions');
}
};

View File

@ -0,0 +1,128 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$driver = DB::getDriverName();
if ($driver === 'sqlite') {
return;
}
if (! Schema::hasColumn('tenants', 'workspace_id')) {
return;
}
DB::transaction(function (): void {
$tenantIds = DB::table('tenants')->whereNull('workspace_id')->pluck('id');
foreach ($tenantIds as $tenantId) {
$workspaceId = DB::table('tenant_memberships')
->join('workspace_memberships', 'workspace_memberships.user_id', '=', 'tenant_memberships.user_id')
->where('tenant_memberships.tenant_id', $tenantId)
->orderByRaw("CASE tenant_memberships.role WHEN 'owner' THEN 0 WHEN 'manager' THEN 1 WHEN 'operator' THEN 2 ELSE 3 END")
->value('workspace_memberships.workspace_id');
if ($workspaceId !== null) {
DB::table('tenants')
->where('id', $tenantId)
->update(['workspace_id' => (int) $workspaceId]);
}
}
$remaining = (int) DB::table('tenants')->whereNull('workspace_id')->count();
if ($remaining === 0) {
return;
}
$legacyWorkspaceId = DB::table('workspaces')->insertGetId([
'name' => 'Legacy Workspace',
'slug' => 'legacy',
'created_at' => now(),
'updated_at' => now(),
]);
$users = DB::table('tenant_memberships')
->join('tenants', 'tenants.id', '=', 'tenant_memberships.tenant_id')
->whereNull('tenants.workspace_id')
->select([
'tenant_memberships.user_id',
DB::raw("MIN(CASE tenant_memberships.role WHEN 'owner' THEN 0 WHEN 'manager' THEN 1 WHEN 'operator' THEN 2 ELSE 3 END) AS role_rank"),
])
->groupBy('tenant_memberships.user_id')
->get();
$roleFromRank = static fn (int $rank): string => match ($rank) {
0 => 'owner',
1 => 'manager',
2 => 'operator',
default => 'readonly',
};
$membershipRows = [];
foreach ($users as $user) {
$membershipRows[] = [
'workspace_id' => (int) $legacyWorkspaceId,
'user_id' => (int) $user->user_id,
'role' => $roleFromRank((int) $user->role_rank),
'created_at' => now(),
'updated_at' => now(),
];
}
if ($membershipRows !== []) {
DB::table('workspace_memberships')->insertOrIgnore($membershipRows);
}
DB::table('tenants')
->whereNull('workspace_id')
->update(['workspace_id' => (int) $legacyWorkspaceId]);
});
if ($driver === 'pgsql') {
DB::statement('ALTER TABLE tenants ALTER COLUMN workspace_id SET NOT NULL');
return;
}
if ($driver === 'mysql') {
DB::statement('ALTER TABLE tenants MODIFY workspace_id BIGINT UNSIGNED NOT NULL');
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$driver = DB::getDriverName();
if ($driver === 'sqlite') {
return;
}
if (! Schema::hasColumn('tenants', 'workspace_id')) {
return;
}
if ($driver === 'pgsql') {
DB::statement('ALTER TABLE tenants ALTER COLUMN workspace_id DROP NOT NULL');
return;
}
if ($driver === 'mysql') {
DB::statement('ALTER TABLE tenants MODIFY workspace_id BIGINT UNSIGNED NULL');
}
}
};

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('managed_tenant_onboarding_sessions')) {
return;
}
Schema::create('managed_tenant_onboarding_sessions', function (Blueprint $table) {
$table->id();
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('current_step')->nullable();
$table->json('state')->nullable();
$table->foreignId('started_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('updated_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
$table->unique(['workspace_id', 'tenant_id']);
$table->index(['tenant_id']);
});
}
public function down(): void
{
Schema::dropIfExists('managed_tenant_onboarding_sessions');
}
};

View File

@ -13,25 +13,10 @@
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
<div class="font-medium text-gray-900 dark:text-gray-100">No tenants are available for your account.</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
@if ($this->canRegisterTenant())
Register a tenant for this workspace, or switch workspaces.
@else
Switch workspaces, or contact an administrator.
@endif
Switch workspaces, or contact an administrator.
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
@if ($this->canRegisterTenant())
<x-filament::button
type="button"
color="primary"
tag="a"
href="{{ route('filament.admin.tenant.registration') }}"
>
Register tenant
</x-filament::button>
@endif
<x-filament::button
type="button"
color="gray"

View File

@ -0,0 +1,170 @@
<x-filament-panels::page>
<x-filament::section>
<div class="flex flex-col gap-4">
<div class="text-sm text-gray-600 dark:text-gray-300">
Workspace: <span class="font-medium text-gray-900 dark:text-gray-100">{{ $this->workspace->name }}</span>
</div>
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
<div class="font-medium text-gray-900 dark:text-gray-100">
Managed tenant onboarding
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
This wizard will guide you through identifying a managed tenant and verifying access.
</div>
</div>
@if ($this->managedTenant)
<div class="rounded-md border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-200">
<div class="font-medium text-gray-900 dark:text-gray-100">Identified tenant</div>
<dl class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Name</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $this->managedTenant->name }}</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Tenant ID</dt>
<dd class="mt-1 font-mono text-sm text-gray-900 dark:text-gray-100">{{ $this->managedTenant->tenant_id }}</dd>
</div>
</dl>
</div>
@endif
@php
$verificationSucceeded = $this->verificationSucceeded();
$hasTenant = (bool) $this->managedTenant;
$hasConnection = $hasTenant && is_int($this->selectedProviderConnectionId) && $this->selectedProviderConnectionId > 0;
@endphp
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 1 Identify managed tenant</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Provide tenant ID + display name to start or resume the flow.</div>
</div>
<div class="text-xs font-medium {{ $hasTenant ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
{{ $hasTenant ? 'Done' : 'Pending' }}
</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="primary"
wire:click="mountAction('identifyManagedTenant')"
>
{{ $hasTenant ? 'Change tenant' : 'Identify tenant' }}
</x-filament::button>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 2 Provider connection</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Create or pick the connection used to verify access.</div>
</div>
<div class="text-xs font-medium {{ $hasConnection ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
{{ $hasConnection ? 'Selected' : ($hasTenant ? 'Pending' : 'Locked') }}
</div>
</div>
@if ($hasTenant)
<div class="mt-3 text-sm text-gray-700 dark:text-gray-200">
<span class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Selected connection ID</span>
<div class="mt-1 font-mono">{{ $this->selectedProviderConnectionId ?? '—' }}</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="gray"
wire:click="mountAction('createProviderConnection')"
>
Create connection
</x-filament::button>
<x-filament::button
type="button"
color="gray"
wire:click="mountAction('selectProviderConnection')"
>
Select connection
</x-filament::button>
</div>
@endif
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 3 Verify access</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Runs a verification operation and records the result.</div>
</div>
<div class="text-xs font-medium {{ $verificationSucceeded ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
{{ $verificationSucceeded ? 'Succeeded' : ($hasConnection ? 'Pending' : 'Locked') }}
</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="primary"
:disabled="! $hasConnection"
wire:click="mountAction('startVerification')"
>
Run verification
</x-filament::button>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 4 Bootstrap (optional)</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Start inventory/compliance sync after verification.</div>
</div>
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ $verificationSucceeded ? 'Available' : 'Locked' }}
</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="gray"
:disabled="! $verificationSucceeded"
wire:click="mountAction('startBootstrap')"
>
Start bootstrap
</x-filament::button>
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 5 Complete onboarding</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Marks the tenant as active after successful verification.</div>
</div>
<div class="text-xs font-medium {{ $verificationSucceeded ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
{{ $verificationSucceeded ? 'Ready' : 'Locked' }}
</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="success"
:disabled="! $verificationSucceeded"
wire:click="mountAction('completeOnboarding')"
>
Complete onboarding
</x-filament::button>
</div>
</div>
</div>
</x-filament::section>
</x-filament-panels::page>

View File

@ -17,16 +17,14 @@
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
@if ($this->canRegisterTenant())
<x-filament::button
type="button"
color="primary"
tag="a"
href="{{ route('filament.admin.tenant.registration') }}"
>
Add managed tenant
</x-filament::button>
@endif
<x-filament::button
type="button"
color="primary"
tag="a"
href="{{ route('admin.workspace.managed-tenants.onboarding', ['workspace' => $this->workspace->slug ?? $this->workspace->getKey()]) }}"
>
Start onboarding
</x-filament::button>
<x-filament::button
type="button"

View File

@ -1,6 +1,5 @@
<?php
use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Filament\Pages\TenantDashboard;
use App\Http\Controllers\AdminConsentCallbackController;
use App\Http\Controllers\Auth\EntraController;
@ -29,7 +28,7 @@
Route::get('/admin/consent/start', TenantOnboardingController::class)
->name('admin.consent.start');
// Panel root override: keep the app's workspace-first flow.
// Avoid Filament's tenancy root redirect which otherwise sends users to /admin/register-tenant
// Avoid Filament's tenancy root redirect which otherwise sends users into legacy flows.
// when no default tenant can be resolved.
Route::middleware([
'web',
@ -67,7 +66,7 @@
$tenantCount = (int) $tenantsQuery->count();
if ($tenantCount === 0) {
return redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]);
return redirect()->route('admin.workspace.managed-tenants.onboarding', ['workspace' => $workspace->slug ?? $workspace->getKey()]);
}
if ($tenantCount === 1) {
@ -81,23 +80,6 @@
return redirect()->to('/admin/choose-tenant');
})
->name('admin.home');
// Fallback route: Filament's layout generates this URL when tenancy registration is enabled.
// In this app, package route registration may not always define it early enough, which breaks
// rendering on tenant-scoped routes.
Route::middleware([
'web',
'panel:admin',
'ensure-correct-guard:web',
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
])
->prefix('/admin')
->name('filament.admin.')
->get('/register-tenant', RegisterTenant::class)
->name('tenant.registration');
Route::get('/admin/rbac/start', [RbacDelegatedAuthController::class, 'start'])
->name('admin.rbac.start');
@ -112,42 +94,6 @@
->middleware('throttle:entra-callback')
->name('auth.entra.callback');
Route::middleware(['web', 'auth', 'ensure-workspace-selected'])
->get('/admin/managed-tenants', function (Request $request) {
$workspace = app(WorkspaceContext::class)->currentWorkspace($request);
if (! $workspace instanceof Workspace) {
return redirect('/admin/choose-workspace');
}
return redirect('/admin/w/'.($workspace->slug ?? $workspace->getKey()).'/managed-tenants');
})
->name('admin.legacy.managed-tenants.index');
Route::middleware(['web', 'auth', 'ensure-workspace-selected'])
->get('/admin/managed-tenants/onboarding', function (Request $request) {
$workspace = app(WorkspaceContext::class)->currentWorkspace($request);
if (! $workspace instanceof Workspace) {
return redirect('/admin/choose-workspace');
}
return redirect('/admin/w/'.($workspace->slug ?? $workspace->getKey()).'/managed-tenants/onboarding');
})
->name('admin.legacy.managed-tenants.onboarding');
Route::middleware(['web', 'auth', 'ensure-workspace-selected'])
->get('/admin/new', function (Request $request) {
$workspace = app(WorkspaceContext::class)->currentWorkspace($request);
if (! $workspace instanceof Workspace) {
return redirect('/admin/choose-workspace');
}
return redirect('/admin/w/'.($workspace->slug ?? $workspace->getKey()).'/managed-tenants/onboarding');
})
->name('admin.legacy.onboarding');
Route::middleware(['web', 'auth', 'ensure-correct-guard:web'])
->post('/admin/switch-workspace', SwitchWorkspaceController::class)
->name('admin.switch-workspace');
@ -173,11 +119,20 @@
->name('admin.workspace.home');
Route::get('/ping', fn () => response()->noContent())->name('admin.workspace.ping');
Route::get('/managed-tenants/onboarding', fn () => redirect('/admin/register-tenant'))
->name('admin.workspace.managed-tenants.onboarding');
});
Route::middleware([
'web',
'panel:admin',
'ensure-correct-guard:web',
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-member',
])
->get('/admin/w/{workspace}/managed-tenants/onboarding', \App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class)
->name('admin.workspace.managed-tenants.onboarding');
Route::middleware([
'web',
'panel:admin',

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Unified Managed Tenant Onboarding Wizard (073)
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-03
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All checklist items pass.
- The constitution-alignment paragraphs reference platform primitives (e.g., `OperationRun`) and domain integrations (e.g., Microsoft Graph) as required by this repositorys constitution.
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`

View File

@ -0,0 +1,50 @@
openapi: 3.1.0
info:
title: TenantPilot — Managed Tenant Onboarding (073)
version: 0.1.0
description: |
Workspace-scoped onboarding wizard routes. These are UI endpoints (Filament/Livewire),
but documented here for contract clarity.
servers:
- url: https://example.invalid
paths:
/admin/w/{workspace}/managed-tenants:
get:
summary: Managed tenants landing (workspace-scoped)
parameters:
- name: workspace
in: path
required: true
schema:
type: string
responses:
'200':
description: Renders managed tenants landing page.
'403':
description: Workspace member missing required capability (where applicable).
'404':
description: Workspace not found or user not a member (deny-as-not-found).
/admin/w/{workspace}/managed-tenants/onboarding:
get:
summary: Managed tenant onboarding wizard (workspace-scoped)
parameters:
- name: workspace
in: path
required: true
schema:
type: string
responses:
'200':
description: Renders onboarding wizard page.
'403':
description: Workspace member missing onboarding capability.
'404':
description: Workspace not found or user not a member (deny-as-not-found).
/admin/register-tenant:
get:
summary: Legacy tenant registration entry point
deprecated: true
responses:
'404':
description: Must be removed / behave as not found (FR-001).

View File

@ -0,0 +1,57 @@
# Onboarding Wizard — Action Contracts (073)
These are conceptual contracts for the wizards server-side actions (Livewire/Filament).
They define inputs/outputs and authorization semantics.
## Identify tenant
- **Purpose:** Upsert or resume a tenant onboarding session and ensure a single tenant record exists per `(workspace_id, entra_tenant_id)`.
- **Inputs:**
- `entra_tenant_id` (string)
- `name` (string)
- `domain` (string|null)
- **Outputs:**
- `tenant_id` (internal DB id)
- `onboarding_session_id`
- `current_step`
- **Errors:**
- 404: workspace not found or actor not a workspace member
- 403: actor is a workspace member but lacks onboarding capability
## Select or create Provider Connection
- **Purpose:** Attach an existing default connection (if present) or create/select another connection for the tenant.
- **Inputs:**
- `provider_connection_id` (int|null)
- (optional) connection creation fields (non-secret identifiers only)
- **Outputs:**
- `provider_connection_id`
- `is_default`
- **Errors:**
- 404: connection/tenant not in workspace scope
- 403: member missing capability
## Start verification
- **Purpose:** Start provider connection verification asynchronously.
- **Mechanism:** Create/reuse `OperationRun` of type `provider.connection.check`, enqueue `ProviderConnectionHealthCheckJob`.
- **Inputs:** none (uses selected connection)
- **Outputs:**
- `operation_run_id`
- `status` (queued/running/succeeded/failed)
- **Errors:**
- 404: tenant/connection not in workspace scope
- 403: member missing capability
## Optional bootstrap actions
- **Purpose:** Start selected post-verify operations as separate runs.
- **Inputs:** list of operation types (must exist in registry)
- **Outputs:** list of `operation_run_id`
- **Errors:**
- 403/404 semantics as above
## Security & data minimization
- Stored secrets must never be returned.
- Failures are stored as stable reason codes + sanitized messages.

View File

@ -0,0 +1,84 @@
# Data Model — Unified Managed Tenant Onboarding Wizard (073)
## Entities
### Workspace
Existing entity. Onboarding is always initiated within a selected workspace.
### Tenant (Managed Tenant)
Existing model: `App\Models\Tenant`
**Key fields (existing or to be confirmed/extended):**
- `id` (PK)
- `workspace_id` (FK to workspaces)
- `tenant_id` (string; Entra tenant ID) — specs `entra_tenant_id`
- `external_id` (string; globally unique route key used by Filament tenancy)
- `name` (string)
- `domain` (string|null)
- `status` (string) — v1 lifecycle:
- `pending` (created / onboarding)
- `active` (ready)
- `archived` (no longer managed)
**Indexes / constraints (design intent):**
- Unique: `(workspace_id, tenant_id)`
- Keep `external_id` globally unique (for `/admin/t/{tenant}` routing) and do **not** force it to equal `tenant_id`.
**State transitions:**
- `pending``active` after successful verification
- `active``archived` on soft-delete (existing behavior)
- `archived``active` on restore (existing behavior)
### ProviderConnection
Existing model: `App\Models\ProviderConnection`
- Belongs to `Tenant`
- Contains `entra_tenant_id` (string) and default/active flags.
### TenantOnboardingSession (new)
New model/table to persist resumable onboarding state. Must never persist or return secrets.
**Proposed fields:**
- `id` (PK)
- `workspace_id` (FK)
- `tenant_id` (FK to tenants.id) — nullable until tenant is created, depending on wizard flow
- `entra_tenant_id` (string) — denormalized for upsert/idempotency before tenant exists
- `current_step` (string; e.g., `identify`, `connection`, `verify`, `bootstrap`, `complete`)
- `state` (jsonb/json) — safe fields only (no secrets)
- `tenant_name`
- `tenant_domain`
- `selected_provider_connection_id`
- `verification_run_id` (OperationRun id)
- `bootstrap_run_ids` (array)
- `started_by_user_id` (FK users)
- `updated_by_user_id` (FK users)
- `completed_at` (timestamp|null)
- timestamps
**Constraints:**
- Unique: `(workspace_id, entra_tenant_id)`
**State transitions:**
- `in_progress` (implied by `completed_at = null`) → `completed` (`completed_at != null`)
## Validation rules (high level)
- `entra_tenant_id` (`tenant_id`) must be a non-empty string; validate as GUID format if enforced elsewhere.
- Tenant name required to create tenant.
- ProviderConnection selection must belong to the same tenant/workspace.
## Authorization boundaries
- Workspace scope: non-members denied as 404.
- Workspace member but missing onboarding capability: 403.
- Tenant scope: once tenant exists/selected, tenant membership rules apply as currently implemented.

View File

@ -0,0 +1,163 @@
# Implementation Plan: Unified Managed Tenant Onboarding Wizard (073)
**Branch**: `073-unified-managed-tenant-onboarding-wizard` | **Date**: 2026-02-03 | **Spec**: specs/073-unified-managed-tenant-onboarding-wizard/spec.md
**Input**: Feature specification from `specs/073-unified-managed-tenant-onboarding-wizard/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Deliver a single, resumable onboarding wizard for Managed Tenants that: (1) identifies/upserts a managed tenant within the current workspace, (2) attaches or configures a Provider Connection, (3) runs verification asynchronously as an `OperationRun` with sanitized outcomes, and (4) optionally kicks off bootstrap operations.
Implementation approach: reuse existing primitives (`App\Models\Tenant`, Provider Connections, `provider.connection.check` operation type, workspace + tenant isolation middleware, canonical capability registries) and replace legacy tenant registration/redirect entry points with a single workspace-scoped wizard route.
## Technical Context
<!--
ACTION REQUIRED: Replace the content in this section with the technical details
for the project. The structure here is presented in advisory capacity to guide
the iteration process.
-->
**Language/Version**: PHP 8.4.x (Composer constraint: `^8.2`)
**Primary Dependencies**: Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x
**Storage**: PostgreSQL (Sail) + SQLite in tests where applicable
**Testing**: Pest (via `vendor/bin/sail artisan test`)
**Target Platform**: Web app (Sail for local dev; container-based deploy on Linux)
**Project Type**: Web application (Laravel monolith)
**Performance Goals**: Onboarding UI renders DB-only; all Graph calls occur in queued work tracked by `OperationRun`; avoid N+1 via eager loading for any list/detail.
**Constraints**: Tenant isolation (404 vs 403 semantics); no secret material ever returned to the UI/logs; idempotent run-start and onboarding session resume; destructive-like actions require confirmation.
**Scale/Scope**: Workspace-scoped onboarding; expected low volume but high correctness/safety requirements.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
GATE RESULT: PASS (no planned constitution violations).
- Inventory-first: onboarding writes only tenant metadata + configuration pointers; no inventory/snapshot side effects.
- Read/write separation: onboarding creates/updates records and starts operations; all mutating actions are authorized, audited, and tested.
- Graph contract path: verification uses existing `GraphClientInterface` methods (e.g., `getOrganization()`), and runs only in queued jobs.
- Deterministic capabilities: use `App\Support\Auth\Capabilities` + `WorkspaceRoleCapabilityMap`; add a dedicated onboarding capability granted to Owner+Manager.
- RBAC-UX semantics: workspace membership enforced via `ensure-workspace-member`; tenant membership enforced via `EnsureFilamentTenantSelected` / `DenyNonMemberTenantAccess` with deny-as-not-found (404). Missing capability returns 403.
- Destructive confirmation: any archive/delete/credential-rotation actions involved in onboarding must be `->action(...)->requiresConfirmation()`.
- Run observability: verification + optional bootstrap actions start via `OperationRun` and enqueue only; monitoring pages remain DB-only.
- Data minimization: onboarding session stores only non-secret fields; run failures store reason codes + sanitized messages.
- BADGE-001: introduce/extend Managed Tenant status badges via `BadgeCatalog` domain mapping (no per-page mapping).
## Project Structure
### Documentation (this feature)
```text
specs/073-unified-managed-tenant-onboarding-wizard/
├── plan.md # This file (/speckit.plan command output)
├── research.md # Phase 0 output (/speckit.plan command)
├── data-model.md # Phase 1 output (/speckit.plan command)
├── quickstart.md # Phase 1 output (/speckit.plan command)
├── contracts/ # Phase 1 output (/speckit.plan command)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
```
### Source Code (repository root)
<!--
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
for this feature. Delete unused options and expand the chosen structure with
real paths (e.g., apps/admin, packages/something). The delivered plan must
not include Option labels.
-->
```text
app/
├── Filament/
│ ├── Pages/
│ │ └── Workspaces/
│ │ ├── ManagedTenantsLanding.php
│ │ └── (new) ManagedTenantOnboardingWizard.php
│ └── Pages/Tenancy/
│ └── RegisterTenant.php # legacy entry point to remove/disable
├── Http/Controllers/
│ └── TenantOnboardingController.php # legacy admin-consent helper; evaluate usage
├── Jobs/
│ └── ProviderConnectionHealthCheckJob.php # verification via OperationRun
├── Models/
│ ├── Tenant.php
│ ├── ProviderConnection.php
│ └── (new) TenantOnboardingSession.php
└── Services/
├── Auth/
│ ├── WorkspaceCapabilityResolver.php
│ └── WorkspaceRoleCapabilityMap.php
├── Providers/
│ ├── ProviderOperationRegistry.php
│ └── ProviderGateway.php
└── Graph/
└── GraphClientInterface.php
database/migrations/
├── (new) *_add_workspace_scoped_unique_tenant_id.php
└── (new) *_create_tenant_onboarding_sessions_table.php
routes/web.php
tests/Feature/
└── (new) ManagedTenantOnboardingWizardTest.php
```
**Structure Decision**: Laravel web application (monolith). Onboarding wizard is a Filament page mounted on a workspace-scoped route under `/admin/w/{workspace}/...` (no tenant context required to start).
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
No constitution violations are anticipated for this feature.
## Phase 0 — Outline & Research (complete)
Outputs:
- `research.md`: decisions + rationale + alternatives (no unresolved clarifications).
Key research conclusions:
- Reuse `App\Models\Tenant` as “Managed Tenant” (no new base concept), but introduce `pending` status for the onboarding lifecycle.
- Replace legacy onboarding/registration routes (`/admin/register-tenant`, redirects under `/admin/managed-tenants/*`) with a single workspace-scoped onboarding wizard.
- Use existing provider verification operation type (`provider.connection.check`) executed via `ProviderConnectionHealthCheckJob` with `OperationRun` tracking.
## Phase 1 — Design & Contracts (complete)
Outputs:
- `data-model.md`: entities, fields, relationships, validation, state transitions.
- `contracts/*`: documented HTTP routes + action contracts (OpenAPI-style where applicable).
- `quickstart.md`: dev notes, env vars, how to run tests.
Design highlights:
- Data model
- Tenants: change status lifecycle to include `pending`, ensure `workspace_id` is NOT NULL + FK, and enforce global uniqueness of `tenant_id` (Entra tenant ID) bound to exactly one workspace.
- Onboarding sessions: new table/model for resumable state (strictly non-secret) keyed by `(workspace_id, tenant_id)`.
- Authorization
- Introduce a workspace capability for onboarding (e.g., `workspace_managed_tenant.onboard`) and map it to Owner+Manager via `WorkspaceRoleCapabilityMap`.
- Enforce server-side authorization for every mutation and operation-start; 404 for non-members and cross-workspace access; 403 for members missing capability.
- Runs
- Verification is a queued `OperationRun` using `provider.connection.check`.
- Optional bootstrap actions become separate `OperationRun` types (only if they exist in the ProviderOperationRegistry).
## Phase 2 — Implementation Plan (to be executed by /speckit.tasks)
This plan intentionally stops before creating `tasks.md`.
Proposed sequencing for tasks:
1) Introduce `TenantOnboardingSession` model + migration, and add workspace-scoped uniqueness for tenants.
2) Implement `ManagedTenantOnboardingWizard` page mounted at `/admin/w/{workspace}/managed-tenants/onboarding`.
3) Wire verification start to existing `ProviderConnectionHealthCheckJob` / `provider.connection.check` operation.
4) Remove/disable legacy entry points (`RegisterTenant`, redirect routes) and ensure “not found” behavior.
5) Add Pest feature tests for: 404 vs 403 semantics, idempotency, resumability, and sanitized run outcomes.

View File

@ -0,0 +1,35 @@
# Quickstart — Unified Managed Tenant Onboarding Wizard (073)
## Local setup
- Start containers: `vendor/bin/sail up -d`
- Install deps (if needed): `vendor/bin/sail composer install` and `vendor/bin/sail npm install`
- Run migrations: `vendor/bin/sail artisan migrate`
- Run frontend build/dev:
- `vendor/bin/sail npm run dev` (watch)
- or `vendor/bin/sail npm run build`
## Using the wizard (expected flow)
1) Sign in to `/admin`.
2) Choose a workspace at `/admin/choose-workspace`.
3) Open `/admin/w/{workspace}/managed-tenants`.
4) Start onboarding at `/admin/w/{workspace}/managed-tenants/onboarding`.
5) Complete Identify → Connection → Verify (queued) → optional Bootstrap.
Notes:
- The onboarding UI must render DB-only; Graph calls occur only in queued work.
- Verification is tracked as an `OperationRun` (module `health_check`).
## Tests
Run targeted tests (expected file name when implemented):
- `vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php`
## Deploy / Ops
If Filament assets are used/registered, deployment must include:
- `php artisan filament:assets`

View File

@ -0,0 +1,62 @@
# Research — Unified Managed Tenant Onboarding Wizard (073)
This document resolves planning unknowns and records key implementation decisions.
## Decisions
### 1) Managed Tenant model = existing `Tenant`
- **Decision:** Treat the existing `App\Models\Tenant` as the “Managed Tenant” concept.
- **Rationale:** The admin panel tenancy, membership model, and most operational flows already key off `Tenant`.
- **Alternatives considered:**
- Introduce a new `ManagedTenant` model/table.
- Keep `Tenant` as-is and build onboarding as “just another page”.
- **Why rejected:** A second tenant-like model would duplicate authorization, routing, and operational conventions.
### 2) Workspace-scoped uniqueness + stable route key
- **Decision:** Enforce uniqueness by `(workspace_id, tenant_id)` (where `tenant_id` is the Entra tenant ID), and ensure Filaments route tenant key stays globally unique.
- **Rationale:** The feature spec explicitly defines the uniqueness key, and cross-workspace safety requires first-class scoping.
- **Implementation note:** Today `tenants.external_id` is unique and is force-set to `tenant_id` in `Tenant::saving()`. If we allow the same `tenant_id` across workspaces, `external_id` must NOT be set to `tenant_id` anymore. Prefer a generated opaque stable `external_id` (UUID) and keep `tenant_id` strictly as the business identifier.
- **Alternatives considered:**
- Keep global uniqueness on `tenant_id` and keep using `external_id = tenant_id`.
- **Why rejected:** Conflicts with the clarified uniqueness key and complicates “deny-as-not-found” behavior via DB constraint errors.
### 3) Wizard route location = workspace-scoped (`/admin/w/{workspace}/...`)
- **Decision:** Mount onboarding at a workspace-scoped route: `/admin/w/{workspace}/managed-tenants/onboarding`.
- **Rationale:** This path is explicitly exempted from forced tenant selection in `EnsureFilamentTenantSelected`, allowing onboarding before a tenant exists.
- **Alternatives considered:**
- Tenant-scoped Filament routes (`/admin/t/{tenant}/...`).
- Reusing Filaments built-in tenant registration page (`tenantRegistration`).
- **Why rejected:** Tenant-scoped routes require a tenant to exist/selected; built-in registration is a legacy entry point we must remove.
### 4) Verification implementation = existing provider operation (`provider.connection.check`)
- **Decision:** Use `provider.connection.check` (module `health_check`) executed via `ProviderConnectionHealthCheckJob` as the onboarding verification run.
- **Rationale:** It already uses `OperationRun`, writes sanitized outcomes, and performs Graph calls off-request.
- **Alternatives considered:**
- New onboarding-specific operation type.
- **Why rejected:** Adds duplication without a clear benefit for v1.
### 5) Authorization surface = workspace capability (Owner+Manager)
- **Decision:** Add a dedicated workspace capability for onboarding (e.g., `workspace_managed_tenant.onboard`) and grant it to workspace Owner and Manager in `WorkspaceRoleCapabilityMap`.
- **Rationale:** The spec requires Owner+Manager; existing workspace capabilities dont exactly match this (e.g., `WORKSPACE_MANAGE` is Owner-only).
- **Alternatives considered:**
- Check workspace role strings (`owner/manager`) directly.
- Reuse an unrelated capability like `WORKSPACE_MEMBERSHIP_MANAGE`.
- **Why rejected:** Constitution forbids role-string checks in feature code; reusing unrelated capability broadens authorization implicitly.
### 6) Legacy entry points = removed/404 (no redirects)
- **Decision:** Remove/disable these entry points and ensure 404 behavior:
- `/admin/register-tenant` (Filament registration page)
- `/admin/managed-tenants*` legacy redirects
- `/admin/new` redirect
- `/admin/w/{workspace}/managed-tenants/onboarding` redirect stub
- **Rationale:** FR-001 requires wizard-only entry and “not found” behavior.
## Open Questions
- None. All technical unknowns required for planning are resolved.

View File

@ -0,0 +1,185 @@
# Feature Specification: Unified Managed Tenant Onboarding Wizard (073)
**Feature Branch**: `073-unified-managed-tenant-onboarding-wizard`
**Created**: 2026-02-03
**Status**: Draft
**Input**: User description: "Single, unified onboarding wizard for Managed Tenants (create/attach connection, verify, optional bootstrap), removing all legacy entry points."
## Clarifications
### Session 2026-02-03
- Q: Which workspace roles can start the onboarding wizard? → A: Only `owner` and `manager`.
- Q: If Provider Connections already exist, what should Step 2 do? → A: Auto-use the existing default connection (and allow switching).
- Q: What is the canonical uniqueness key for a Managed Tenant? → A: Unique globally by `tenant_id` (Entra tenant ID) and bound to exactly one workspace.
- Q: Which Managed Tenant status values exist in v1? → A: `pending`, `active`, `archived`.
- Q: Who can resume an existing onboarding session? → A: Any workspace `owner/manager` with the onboarding capability (shared session per tenant).
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Start Managed Tenant onboarding (Priority: P1)
As a workspace member with the required capability, I can start a single guided onboarding flow that creates (or resumes) a Managed Tenant in the current workspace, so that the tenant is always created consistently and safely.
**Why this priority**: This is the primary entry point and eliminates inconsistent/unsafe creation paths.
**Independent Test**: Can be fully tested by starting the onboarding in an empty workspace, completing step 1, and confirming a single Managed Tenant exists and is bound to that workspace.
**Acceptance Scenarios**:
1. **Given** a user has selected a workspace and has permission to onboard tenants, **When** they complete “Identify Managed Tenant”, **Then** exactly one Managed Tenant record exists for that workspace and tenant identifier.
2. **Given** a user repeats the same step with the same tenant identifier, **When** they submit again, **Then** no duplicate Managed Tenant is created and the existing onboarding session is continued.
---
### User Story 2 - Configure a connection and verify access (Priority: P2)
As a workspace member with the required capability, I can configure (or attach) a Provider Connection for the Managed Tenant and trigger a verification run, so that connectivity and permissions are validated without exposing secrets.
**Why this priority**: Without a validated connection, the tenant cannot be safely managed.
**Independent Test**: Can be tested by completing the “Connection” step and starting a verification run, then asserting the run is created with the expected scope and that no secrets appear in run outputs.
**Acceptance Scenarios**:
1. **Given** a Managed Tenant exists in the current workspace, **When** a user configures a connection, **Then** the system stores the connection as configured without ever showing stored secret material back to the user.
2. **Given** a user confirms they granted consent, **When** they trigger verification, **Then** a background verification run is started and is visible as “queued / running / succeeded / failed” with a sanitized outcome.
---
### User Story 3 - Resume and complete onboarding (Priority: P3)
As a workspace member, I can resume an incomplete onboarding session and complete optional bootstrap actions, so that interrupted onboarding does not create duplicates and finishes in a “ready” state.
**Why this priority**: Real onboarding often pauses for consent/approvals; resumability reduces rework and errors.
**Independent Test**: Can be tested by starting onboarding, leaving it incomplete, resuming, and finishing; then verifying the tenant is “ready” and optional actions create separate runs.
**Acceptance Scenarios**:
1. **Given** onboarding was started but not completed, **When** the user returns later, **Then** they can resume at the correct step with previously entered (non-secret) state.
2. **Given** verification succeeded, **When** the user chooses optional bootstrap actions, **Then** each selected action starts its own background run and onboarding can still be completed.
---
### Edge Cases
- Cross-workspace isolation: a tenant identifier that exists in a different workspace must not be attachable or discoverable (deny-as-not-found).
- Missing capability: members without the required capability see disabled UI affordances, and server-side requests are denied.
- Roles and capabilities: `operator` and `readonly` members cannot start onboarding by default.
- Resume permissions: onboarding can be resumed by any authorized workspace `owner/manager` (not only the initiator).
- Verification failures: outcomes must be actionable (reason code + safe message) and never leak tokens/secrets.
- Idempotency: repeated submissions or refreshes must not create duplicate tenants, duplicate default connections, or a runaway number of active verification runs.
- Last-owner protections: demoting/removing the last owner (workspace or managed tenant) is blocked and recorded for audit.
## Requirements *(mandatory)*
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior,
or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
- state which authorization plane(s) are involved (tenant `/admin/t/{tenant}` vs platform `/system`),
- ensure any cross-plane access is deny-as-not-found (404),
- explicitly define 404 vs 403 semantics:
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
- member but missing capability → 403
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
- ensure destructive-like actions require confirmation (`->requiresConfirmation()`),
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
### Scope & Assumptions
**In scope (v1)**
- A single onboarding wizard to create or resume onboarding of a Managed Tenant within a selected workspace.
- Configure or attach a Provider Connection, guide consent, start verification runs, and optionally start bootstrap runs.
- Completion marks the tenant as ready/active and routes the user to the tenant details.
- Removal of all legacy UI entry points for creating/onboarding tenants (no redirects).
**Out of scope (v1)**
- User invitation workflows.
- Group-based auto-provisioning.
- Full compliance/evidence reporting.
- Cloud resource provisioning.
**Dependencies**
- Workspace selection/context and workspace membership.
- A managed-tenant concept bound to exactly one workspace.
- Provider Connections and secure credential storage.
- A run system to track verification and bootstrap actions.
- Audit logging and a canonical capability registry.
**Assumptions**
- Default policy: the onboarding initiator becomes workspace manager and managed-tenant owner (or the closest minimum-privilege equivalents).
- “Not found” behavior is used to avoid leaking the existence of out-of-scope tenants.
### Acceptance Coverage
The following acceptance coverage is required to treat the feature as complete:
- Legacy entry points removed (not found behavior).
- Workspace isolation enforced (cross-workspace attach/visibility prevented).
- Idempotency verified (no duplicates created by repeated submissions).
- Verification run creation and sanitized failure reporting.
- Last-owner protections enforced and auditable.
### Functional Requirements
- **FR-001 (Single entry point)**: System MUST provide exactly one UI flow to onboard a Managed Tenant (the onboarding wizard), and all other “add tenant” entry points MUST be removed and behave as “not found”.
- **FR-002 (Workspace-first enforcement)**: System MUST require an active workspace context for onboarding and tenant-scoped access.
- **FR-003 (Hard isolation)**: System MUST deny-as-not-found (404 semantics) when a Managed Tenant does not belong to the current workspace, including for attempts to attach an existing tenant identifier from another workspace.
- **FR-004 (Authorization semantics)**: System MUST enforce authorization server-side for all onboarding mutations and run-start actions. Non-member / not entitled to tenant scope MUST be treated as 404 semantics; a member lacking the required capability MUST be treated as 403 semantics. By default, only workspace `owner` and `manager` can start the onboarding wizard.
- **FR-005 (Capabilities-first)**: System MUST authorize via canonical capabilities (not role string comparisons in feature code).
- **FR-006 (Idempotent tenant identification)**: System MUST upsert tenant identification by a stable tenant identifier within the workspace, so repeating step 1 never creates duplicates.
- **FR-006a (Tenant uniqueness key)**: System MUST enforce a single Managed Tenant globally per `tenant_id` (Entra tenant ID) and bind it to exactly one workspace.
- **FR-007 (Onboarding session resumability)**: System MUST persist onboarding state (excluding secret material) so the flow can be resumed after interruption without data inconsistency.
- **FR-007a (Shared resumability)**: An onboarding session MUST be resumable by any authorized workspace `owner/manager` with the onboarding capability (not only the user who started it).
- **FR-008 (Connection handling)**: System MUST allow creating or attaching a Provider Connection during onboarding and MUST never display stored secret material back to users; UI MUST only show safe configuration indicators (e.g., configured yes/no, last rotation timestamp).
- **FR-008a (Default connection selection)**: If one or more Provider Connections already exist for the Managed Tenant, Step 2 MUST auto-select the default connection and MAY allow the user to switch to a different existing connection.
- **FR-009 (Verification as runs)**: System MUST start verification as a background run with clear status and a sanitized result (reason code + short safe message).
- **FR-010 (DB-only UI rendering)**: System MUST render onboarding UI using only stored data; any external calls required for verification MUST occur only in background work.
- **FR-011 (Operational clarity)**: System MUST display verification outcomes and missing requirements in a user-actionable way (what is missing, what to do next) without leaking sensitive details.
- **FR-012 (Optional bootstrap actions)**: System MUST support optional post-verify bootstrap actions that each start their own background run and do not block completion unless explicitly selected.
- **FR-013 (Completion state)**: System MUST mark the Managed Tenant as ready/active only after successful verification, and MUST redirect users to the Managed Tenant details view upon completion.
- **FR-013a (Status model)**: System MUST use a v1 Managed Tenant lifecycle with statuses: `pending` (created/onboarding), `active` (ready), `archived` (no longer managed).
- **FR-014 (Membership bootstrap)**: System MUST ensure the onboarding initiator receives the minimum required memberships in the workspace and the managed tenant scope according to policy (default: workspace manager + tenant owner).
- **FR-015 (Last-owner protections)**: System MUST block demotion/removal of the last owner at both workspace scope and managed tenant scope, and MUST record the blocked attempt for audit.
- **FR-016 (Auditability)**: System MUST record audit events for tenant creation, connection creation/rotation, verification start/result, membership changes, and last-owner blocks.
### Key Entities *(include if feature involves data)*
- **Workspace**: A portfolio/customer context that owns memberships and one or more Managed Tenants.
- **Managed Tenant**: A managed Entra/Intune tenant, uniquely identified within a workspace by an external tenant identifier, with lifecycle status (e.g., pending/ready/archived).
- Uniqueness: exactly one globally per `tenant_id` (Entra tenant ID), bound to exactly one workspace.
- Status values (v1): `pending`, `active`, `archived`.
- **Provider Connection**: A technical connection configuration that enables access to a Managed Tenant; includes secure credentials/configuration metadata and enabled/default flags.
- **Onboarding Session**: A persistent record of onboarding progress and safe state to support resumability and idempotency.
- **Verification Run**: A background run that validates connectivity and required permissions and produces a sanitized outcome.
- **Membership (Workspace-scoped / Tenant-scoped)**: Defines who can see and operate within a workspace and on a specific managed tenant.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001 (Time-to-onboard)**: A workspace admin can complete the wizard up to starting verification in under 3 minutes (excluding external consent/approval waiting time).
- **SC-002 (Idempotency)**: Re-running any wizard step does not create duplicates (0 duplicate tenants per tenant identifier per workspace; 0 duplicate default connections per tenant).
- **SC-003 (Authorization correctness)**: For all onboarding endpoints/actions, non-members see no discoverability and get 404 semantics; members without capability get 403 semantics; authorized users can complete the flow.
- **SC-004 (Secret safety)**: No secrets/tokens are present in run outputs, notifications, audit entries, or error messages (validated by automated tests that assert redaction/sanitization behavior).
- **SC-005 (Operational clarity)**: When verification fails, users can identify the failure reason category (via reason code + safe message) and see the next step without contacting support.
### Badge Semantics (BADGE-001)
- Managed Tenant status badges MUST map from the canonical status set (`pending`, `active`, `archived`) using a centralized mapping (no ad-hoc per-page mapping).

View File

@ -0,0 +1,159 @@
---
description: "Tasks for Unified Managed Tenant Onboarding Wizard (073)"
---
# Tasks: Unified Managed Tenant Onboarding Wizard (073)
**Input**: Design documents from `specs/073-unified-managed-tenant-onboarding-wizard/`
**Tests**: Required (Pest). Use `vendor/bin/sail artisan test --compact ...`.
## Phase 1: Setup
- [X] T001 Confirm Sail is running and DB is reachable using docker-compose.yml (command: `vendor/bin/sail up -d`)
- [X] T002 Confirm baseline tests pass for the branch using phpunit.xml and tests/ (command: `vendor/bin/sail artisan test --compact`)
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Shared primitives required by all user stories (authz, data model, safety semantics).
- [X] T003 Add onboarding capability constant in app/Support/Auth/Capabilities.php
- [X] T004 Add onboarding capability mapping for Owner+Manager in app/Services/Auth/WorkspaceRoleCapabilityMap.php
- [X] T005 Implement Gate/Policy for onboarding authorization in app/Providers/AuthServiceProvider.php (enforce capabilities; no role-string checks)
- [X] T006 [P] Create TenantOnboardingSession model in app/Models/TenantOnboardingSession.php
- [X] T007 Create onboarding sessions migration in database/migrations/*_create_tenant_onboarding_sessions_table.php (unique workspace_id + tenant_id)
- [X] T008 Create tenant workspace binding migration in database/migrations/*_enforce_tenant_workspace_binding.php (ensure tenants.workspace_id is NOT NULL + FK; ensure tenants.tenant_id remains globally unique; deny cross-workspace duplicates)
- [X] T009 Verify tenant routing key strategy for v1: keep existing Filament tenant route-key stable (do NOT change external_id strategy in this feature); add a regression test that /admin/t/{tenant} continues to resolve the intended managed tenant
- [X] T010 [P] Add foundational authorization + data-model tests in tests/Feature/ManagedTenantOnboardingWizardTest.php (capability known, mapping correct, migrations applied)
**Checkpoint**: Foundational complete — user story work can begin.
---
## Phase 3: User Story 1 — Start Managed Tenant onboarding (Priority: P1) 🎯 MVP
**Goal**: Start or resume a workspace-scoped onboarding wizard and create exactly one Managed Tenant per global-unique `tenant_id` (Entra tenant ID), bound to exactly one workspace.
**Independent Test**: Start onboarding in an empty workspace and complete “Identify Managed Tenant”; assert exactly one tenant exists and a session is created/resumed.
- [X] T011 [P] [US1] Add wizard page class in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (Filament v5 / Livewire v4)
- [X] T012 [P] [US1] Add wizard view in resources/views/filament/pages/workspaces/managed-tenant-onboarding-wizard.blade.php
- [X] T013 [US1] Register wizard route in routes/web.php at `/admin/w/{workspace}/managed-tenants/onboarding` with `ensure-workspace-member` middleware and 404 semantics for non-members
- [X] T014 [US1] Implement wizard mount + workspace loading in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (abort 404 for non-member, 403 for missing onboarding capability)
- [X] T015 [US1] Implement Step 1 “Identify Managed Tenant” upsert in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (transactional; idempotent by workspace_id + tenant_id; tenant status `pending`)
- [X] T015b [US1] Enforce cross-workspace uniqueness in Step 1: if a tenant with the same tenant_id exists in a different workspace, deny-as-not-found (404) and do not create/update anything
- [X] T015c [US1] Membership bootstrap: after tenant upsert, ensure the initiating user has a Managed Tenant membership of role owner (create if missing); never allow tenant to end up with zero owners
- [X] T016 [US1] Persist/resume onboarding session in app/Models/TenantOnboardingSession.php (no secrets in state)
- [X] T017 [US1] Add audit events for onboarding start/resume in app/Services/Audit/WorkspaceAuditLogger.php (or existing audit service) and call from wizard actions
- [X] T018 [P] [US1] Add happy-path tests in tests/Feature/ManagedTenantOnboardingWizardTest.php (owner/manager can start; tenant created; session created)
- [X] T019 [P] [US1] Add negative auth tests in tests/Feature/ManagedTenantOnboardingWizardTest.php (non-member gets 404; member without capability gets 403)
- [X] T020 [P] [US1] Add idempotency tests in tests/Feature/ManagedTenantOnboardingWizardTest.php (repeat step does not create duplicates)
- [X] T020b [P] [US1] Add tests asserting membership bootstrap: newly created tenant has exactly one owner membership for the initiator; attempting to remove/demote the last owner is blocked (can be a minimal service/policy-level assertion)
- [X] T020c [P] [US1] Add tests asserting cross-workspace protection: if tenant_id exists under another workspace, the wizard returns 404 and does not reveal the existence of that tenant
### Remove legacy entry points (required by FR-001)
- [X] T021 [US1] Remove tenant registration from app/Providers/Filament/AdminPanelProvider.php (drop `->tenantRegistration(...)`)
- [X] T022 [US1] Remove `/admin/register-tenant` route from routes/web.php (must behave as not found)
- [X] T023 [US1] Replace legacy onboarding redirects with 404 in routes/web.php (`/admin/managed-tenants`, `/admin/managed-tenants/onboarding`, `/admin/new`, workspace onboarding redirect stub)
- [X] T024 [US1] Remove RegisterTenant references in app/Filament/Pages/ChooseTenant.php and app/Filament/Pages/Workspaces/ManagedTenantsLanding.php
- [X] T025 [P] [US1] Add regression tests in tests/Feature/ManagedTenantOnboardingWizardTest.php asserting legacy endpoints return 404 (no redirects)
**Checkpoint**: US1 complete — wizard is the only entry point; onboarding start is safe + idempotent.
---
## Phase 4: User Story 2 — Configure a connection and verify access (Priority: P2)
**Goal**: Attach or create a Provider Connection and start verification as an `OperationRun` without leaking secrets.
**Independent Test**: Select/create connection, start verification, assert an OperationRun is created and job is dispatched; assert no secret material is returned.
- [X] T026 [US2] Implement Step 2 connection selection in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (auto-select default connection; allow switching)
- [X] T027 [US2] Implement connection creation path in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php using app/Models/ProviderConnection.php and app/Services/Providers/CredentialManager.php (never display stored secrets)
- [X] T028 [US2] Persist selected connection id in app/Models/TenantOnboardingSession.php `state` (non-secret)
- [X] T029 [US2] Implement “Start verification” action using app/Services/Providers/ProviderOperationStartGate.php with operation type `provider.connection.check`
- [X] T029b [US2] Enforce/verify dedupe: clicking “Start verification” twice while an active run exists must return the active OperationRun (no second run created); add a focused test (Bus::fake + assert single run)
- [X] T030 [US2] Ensure verification enqueues app/Jobs/ProviderConnectionHealthCheckJob.php and stores `operation_run_id` in onboarding session state
- [X] T031 [US2] Add “View run” navigation to app/Filament/Resources/OperationRunResource.php (link from wizard action notification)
- [X] T032 [P] [US2] Add tests in tests/Feature/ManagedTenantOnboardingWizardTest.php for connection default selection + switching
- [X] T033 [P] [US2] Add tests in tests/Feature/ManagedTenantOnboardingWizardTest.php for verification run creation + job dispatch (Bus::fake)
- [X] T034 [P] [US2] Add secret-safety tests in tests/Feature/ManagedTenantOnboardingWizardTest.php (no secret fields appear in response/session/run failure summary)
**Checkpoint**: US2 complete — verification is observable via OperationRun and secrets are safe.
---
## Phase 5: User Story 3 — Resume and complete onboarding (Priority: P3)
**Goal**: Resume an onboarding session, run optional bootstrap actions, and complete onboarding to activate the tenant.
**Independent Test**: Start onboarding, leave incomplete, resume as a different authorized owner/manager, complete verification + bootstrap, then mark tenant active.
- [X] T035 [US3] Implement session resume logic in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (load by workspace_id + tenant_id; shared resumability)
- [X] T036 [US3] Implement Step gating in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (cannot complete until verification succeeded)
- [X] T037 [US3] Implement optional bootstrap actions in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (start operations listed in app/Services/Providers/ProviderOperationRegistry.php)
- [X] T038 [US3] Persist bootstrap `operation_run_id`s in app/Models/TenantOnboardingSession.php `state`
- [X] T039 [US3] Implement completion: set tenant status `active`, set onboarding session `completed_at`, redirect to tenant dashboard (app/Filament/Pages/TenantDashboard.php)
- [X] T040 [P] [US3] Add tests in tests/Feature/ManagedTenantOnboardingWizardTest.php for resume by different authorized actor
- [X] T041 [P] [US3] Add tests in tests/Feature/ManagedTenantOnboardingWizardTest.php for completion and tenant status transition `pending``active`
- [X] T042 [P] [US3] Add tests in tests/Feature/ManagedTenantOnboardingWizardTest.php for bootstrap run creation (one OperationRun per selected action)
**Checkpoint**: US3 complete — onboarding is resumable and completes safely.
---
## Phase 6: Polish & Cross-Cutting Concerns
- [X] T043 Add Managed Tenant status badge mapping via BadgeCatalog/BadgeRenderer in app/Support/Badges/* (BADGE-001) and add mapping test in tests/Feature/Badges/TenantStatusBadgeTest.php
- [X] T044 Verify/extend audit coverage for FR-016: use stable audit action IDs (enum/registry), ensure redaction, and add at least one concrete feature test asserting audit rows for onboarding start + verification start (no secrets in payload)
- [X] T045 Verify last-owner protections cover both workspace + tenant memberships; extend policies if needed in app/Policies/* and add regression tests in tests/Feature/Rbac/*
- [X] T046 Run formatter on touched files (command: `vendor/bin/sail bin pint --dirty`)
- [X] T047 Run targeted test suite for onboarding (command: `vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php`)
### Post-spec hardening (Filament-native UX)
- [X] T048 Refactor onboarding page to a Filament-native Wizard schema (replace header-action modals + step cards; persist per-step progress; keep strict RBAC and existing action methods)
- [X] T049 Fix tenant identify UX: entering an existing tenant GUID must not surface a raw 404 modal; bind legacy unscoped tenants to the current workspace when safely inferable and add a regression test
---
## Dependencies & Execution Order
### User Story completion order
1. US1 (P1) depends on Phase 2 only.
2. US2 (P2) depends on US1 (tenant/session + wizard scaffold).
3. US3 (P3) depends on US2 (verification state + run linking).
### Dependency graph
- Phase 1 → Phase 2 → US1 → US2 → US3 → Polish
---
## Parallel execution examples
### US1 parallel work
- [P] T011 and T012 can be implemented in parallel (page class vs blade view).
- [P] T018T020 can be written in parallel (distinct test cases).
### US2 parallel work
- [P] T032T034 can be written in parallel (selection tests vs run tests vs secret-safety tests).
### US3 parallel work
- [P] T040T042 can be written in parallel (resume tests vs completion tests vs bootstrap tests).
---
## Implementation Strategy (MVP)
- MVP scope is US1 only: wizard-only entry point + idempotent tenant identification + resumable session skeleton + required authorization semantics + tests.

View File

@ -4,5 +4,5 @@
it('redirects /admin/new to /admin/login for guests', function (): void {
$this->get('/admin/new')
->assertRedirect('/admin/login');
->assertNotFound();
});

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
it('redacts secret-like fields in workspace audit metadata', function (): void {
$workspace = Workspace::factory()->create();
$actor = User::factory()->create();
/** @var WorkspaceAuditLogger $logger */
$logger = app(WorkspaceAuditLogger::class);
$logger->log(
workspace: $workspace,
action: 'test.redaction',
context: [
'metadata' => [
'access_token' => 'super-secret-token',
'client_secret' => 'super-secret-secret',
'nested' => [
'Authorization' => 'Bearer abc.def.ghi',
'safe' => 'ok',
],
],
],
actor: $actor,
status: 'success',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
$log = AuditLog::query()
->where('workspace_id', (int) $workspace->getKey())
->where('action', 'test.redaction')
->latest('id')
->firstOrFail();
expect($log->metadata['access_token'] ?? null)->toBe('[REDACTED]');
expect($log->metadata['client_secret'] ?? null)->toBe('[REDACTED]');
expect($log->metadata['nested']['Authorization'] ?? null)->toBe('[REDACTED]');
expect($log->metadata['nested']['safe'] ?? null)->toBe('ok');
});

View File

@ -3,6 +3,9 @@
declare(strict_types=1);
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
@ -14,7 +17,18 @@
$this->get('/admin/login')->assertOk();
$user = User::factory()->create();
$this->actingAs($user);
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
]);
$this->get('/admin/no-access')->assertOk();
$this->get('/admin/choose-tenant')->assertOk();

View File

@ -4,32 +4,39 @@
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('does not allow a non-member user to access tenant-scoped admin routes', function () {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$member, $tenant] = createUserWithTenant(
tenant: Tenant::factory()->create(['status' => 'active']),
user: User::factory()->create(),
role: 'owner',
);
$workspace = Workspace::query()->whereKey($tenant->workspace_id)->firstOrFail();
$member = User::factory()->create();
$nonMember = User::factory()->create();
TenantMembership::query()->create([
'tenant_id' => $tenant->getKey(),
'user_id' => $member->getKey(),
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $nonMember->getKey(),
'role' => 'owner',
'source' => 'manual',
'source_ref' => null,
'created_by_user_id' => null,
]);
$this->actingAs($nonMember);
$this->get(TenantDashboard::getUrl(tenant: $tenant))->assertNotFound();
$this->actingAs($nonMember)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(TenantDashboard::getUrl(tenant: $tenant))
->assertNotFound();
$this->actingAs($member);
$this->get(TenantDashboard::getUrl(tenant: $tenant))->assertSuccessful();
$this->actingAs($member)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(TenantDashboard::getUrl(tenant: $tenant))
->assertSuccessful();
$this->get('/system')->assertNotFound();
});

View File

@ -10,7 +10,9 @@
test('backup schedules listing is tenant scoped', function () {
[$user, $tenantA] = createUserWithTenant(role: 'manager');
$tenantB = Tenant::factory()->create();
$tenantB = Tenant::factory()->create([
'workspace_id' => $tenantA->workspace_id,
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'manager');
@ -46,6 +48,10 @@
$this->actingAs($user);
// createUserWithTenant() may be called multiple times in this test; ensure the current
// workspace matches the tenant we are about to access.
session()->put(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
$this->get(route('filament.admin.resources.backup-schedules.index', filamentTenantRouteParams($tenantA)))
->assertOk()
->assertSee('Tenant A schedule')

View File

@ -0,0 +1,18 @@
<?php
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps pending tenant status to a Pending warning badge', function (): void {
$spec = BadgeCatalog::spec(BadgeDomain::TenantStatus, 'pending');
expect($spec->label)->toBe('Pending');
expect($spec->color)->toBe('warning');
expect($spec->icon)->toBe('heroicon-m-clock');
});
it('normalizes tenant status input before mapping', function (): void {
$spec = BadgeCatalog::spec(BadgeDomain::TenantStatus, 'PENDING');
expect($spec->label)->toBe('Pending');
});

View File

@ -90,7 +90,9 @@
test('group detail is tenant-scoped and cross-tenant access is forbidden (403)', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$tenantB = Tenant::factory()->create([
'workspace_id' => $tenantA->workspace_id,
]);
$groupB = EntraGroup::query()->create([
'tenant_id' => $tenantB->getKey(),
@ -103,10 +105,7 @@
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenantA->getKey() => ['role' => 'owner'],
$tenantB->getKey() => ['role' => 'owner'],
]);
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, user: $user, role: 'owner');
$this->actingAs($user)
->get(EntraGroupResource::getUrl('view', ['record' => $groupB], tenant: $tenantA))

View File

@ -18,9 +18,7 @@
]);
$this->user = User::factory()->create();
$this->user->tenants()->syncWithoutDetaching([
$this->tenant->getKey() => ['role' => 'owner'],
]);
[$this->user, $this->tenant] = createUserWithTenant(tenant: $this->tenant, user: $this->user, role: 'owner');
});
it('renders policy version view without any Graph calls during render', function () {

View File

@ -28,7 +28,7 @@
->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin')
->assertRedirect(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]));
->assertRedirect(route('admin.workspace.managed-tenants.onboarding', ['workspace' => $workspace->slug ?? $workspace->getKey()]));
});
it('redirects /admin to choose-tenant when a workspace is selected and has multiple tenants', function (): void {

View File

@ -4,34 +4,30 @@
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('policy detail shows app protection settings in readable sections', function () {
$tenant = Tenant::create([
'tenant_id' => 'local-tenant',
$tenant = Tenant::factory()->create([
'name' => 'Tenant One',
'metadata' => [],
'is_current' => true,
'status' => 'active',
]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$policy = Policy::create([
'tenant_id' => $tenant->id,
$policy = Policy::factory()->create([
'tenant_id' => $tenant->getKey(),
'external_id' => 'policy-1',
'policy_type' => 'appProtectionPolicy',
'display_name' => 'Teams',
'platform' => 'mobile',
]);
PolicyVersion::create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
PolicyVersion::factory()->create([
'tenant_id' => $tenant->getKey(),
'policy_id' => $policy->getKey(),
'version_number' => 1,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
@ -46,11 +42,6 @@
],
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));

View File

@ -29,7 +29,7 @@
->assertDontSee('Register tenant');
});
it('shows the register-tenant CTA for owner workspace members when there are no tenants', function (): void {
it('does not show the register-tenant CTA for owner workspace members when there are no tenants', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
@ -44,5 +44,7 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/choose-tenant')
->assertSuccessful()
->assertSee('Register tenant');
->assertSee('No tenants are available')
->assertSee('Change workspace')
->assertDontSee('Register tenant');
});

View File

@ -3,42 +3,30 @@
use App\Filament\Resources\PolicyResource;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$tenant = Tenant::create([
'tenant_id' => 'local-tenant',
'name' => 'Tenant One',
'metadata' => [],
'is_current' => true,
]);
$tenant->makeCurrent();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->tenant = $tenant;
$this->user = User::factory()->create();
$this->user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$this->user = $user;
});
test('policy detail renders normalized settings for Autopilot profiles', function () {
$policy = Policy::create([
'tenant_id' => $this->tenant->id,
$policy = Policy::factory()->create([
'tenant_id' => $this->tenant->getKey(),
'external_id' => 'autopilot-1',
'policy_type' => 'windowsAutopilotDeploymentProfile',
'display_name' => 'Autopilot Profile A',
'platform' => 'windows',
]);
PolicyVersion::create([
'tenant_id' => $this->tenant->id,
'policy_id' => $policy->id,
PolicyVersion::factory()->create([
'tenant_id' => $this->tenant->getKey(),
'policy_id' => $policy->getKey(),
'version_number' => 1,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
@ -71,17 +59,17 @@
});
test('policy detail renders normalized settings for Enrollment Status Page (ESP)', function () {
$policy = Policy::create([
'tenant_id' => $this->tenant->id,
$policy = Policy::factory()->create([
'tenant_id' => $this->tenant->getKey(),
'external_id' => 'esp-1',
'policy_type' => 'windowsEnrollmentStatusPage',
'display_name' => 'ESP A',
'platform' => 'windows',
]);
PolicyVersion::create([
'tenant_id' => $this->tenant->id,
'policy_id' => $policy->id,
PolicyVersion::factory()->create([
'tenant_id' => $this->tenant->getKey(),
'policy_id' => $policy->getKey(),
'version_number' => 1,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
@ -113,17 +101,17 @@
});
test('policy detail renders normalized settings for platform restrictions (enrollment)', function () {
$policy = Policy::create([
'tenant_id' => $this->tenant->id,
$policy = Policy::factory()->create([
'tenant_id' => $this->tenant->getKey(),
'external_id' => 'enroll-restrict-1',
'policy_type' => 'deviceEnrollmentPlatformRestrictionsConfiguration',
'display_name' => 'Restriction A',
'platform' => 'all',
]);
PolicyVersion::create([
'tenant_id' => $this->tenant->id,
'policy_id' => $policy->id,
PolicyVersion::factory()->create([
'tenant_id' => $this->tenant->getKey(),
'policy_id' => $policy->getKey(),
'version_number' => 1,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,

View File

@ -22,6 +22,8 @@
test('entra group sync runs are listed for the active tenant', function () {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$otherTenant = Tenant::factory()->create();
EntraGroupSyncRun::query()->create([
@ -38,12 +40,6 @@
'status' => EntraGroupSyncRun::STATUS_SUCCEEDED,
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
$otherTenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user)
->get(EntraGroupSyncRunResource::getUrl('index', tenant: $tenant))
->assertOk()
@ -53,7 +49,9 @@
test('entra group sync run view is forbidden cross-tenant (403)', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$tenantB = Tenant::factory()->create([
'workspace_id' => $tenantA->workspace_id,
]);
$runB = EntraGroupSyncRun::query()->create([
'tenant_id' => $tenantB->getKey(),
@ -63,10 +61,7 @@
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenantA->getKey() => ['role' => 'owner'],
$tenantB->getKey() => ['role' => 'owner'],
]);
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, user: $user, role: 'owner');
$this->actingAs($user)
->get(EntraGroupSyncRunResource::getUrl('view', ['record' => $runB], tenant: $tenantA))

View File

@ -2,7 +2,6 @@
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\BackupService;
@ -90,11 +89,10 @@ public function request(string $method, string $path, array $options = []): Grap
$client = new GroupPolicyHydrationGraphClient;
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::create([
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-gpo-hydration',
'name' => 'Tenant One',
'metadata' => [],
'is_current' => true,
'status' => 'active',
]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
@ -102,8 +100,8 @@ public function request(string $method, string $path, array $options = []): Grap
$_SERVER['INTUNE_TENANT_ID'] = $tenant->tenant_id;
$tenant->makeCurrent();
$policy = Policy::create([
'tenant_id' => $tenant->id,
$policy = Policy::factory()->create([
'tenant_id' => $tenant->getKey(),
'external_id' => 'gpo-hydrate',
'policy_type' => 'groupPolicyConfiguration',
'display_name' => 'Admin Templates Alpha',
@ -132,10 +130,7 @@ public function request(string $method, string $path, array $options = []): Grap
metadata: ['source' => 'test', 'backup_set_id' => $backupSet->id],
);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$response = $this
->actingAs($user)

View File

@ -398,16 +398,14 @@
});
test('tenant can be archived and hidden from default lists', function () {
$tenant = Tenant::create([
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-4',
'name' => 'Tenant 4',
'status' => 'active',
]);
$user = User::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(ListTenants::class)
@ -436,64 +434,86 @@
});
test('tenant table archive filter toggles active and archived tenants', function () {
$active = Tenant::create([
$active = Tenant::factory()->create([
'tenant_id' => 'tenant-active',
'name' => 'Active Tenant',
'status' => 'active',
]);
$archived = Tenant::create([
[$user, $active] = createUserWithTenant(tenant: $active, role: 'owner');
$this->actingAs($user);
$archived = Tenant::factory()->create([
'tenant_id' => 'tenant-archived',
'name' => 'Archived Tenant',
'status' => 'active',
'workspace_id' => $active->workspace_id,
]);
$archived->delete();
$user = User::factory()->create();
$this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$active->getKey() => ['role' => 'owner'],
$archived->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($active, true);
$component = Livewire::test(ListTenants::class)
$this->withSession([
\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $active->workspace_id,
]);
session([
\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $active->workspace_id,
]);
$component = Livewire::actingAs($user)
->test(ListTenants::class)
->filterTable('trashed', true)
->assertSee($active->name)
->assertSee($archived->name);
$component
->set('tableFilters.trashed.value', null)
->filterTable('trashed', null)
->assertSee($active->name)
->assertDontSee($archived->name);
$component
->set('tableFilters.trashed.value', 0)
->filterTable('trashed', false)
->assertSee($archived->name)
->assertDontSee($active->name);
});
test('archived tenant can be restored from the table', function () {
$tenant = Tenant::create([
$contextTenant = Tenant::factory()->create([
'tenant_id' => 'tenant-restore-context',
'name' => 'Restore Context Tenant',
'status' => 'active',
]);
[$user, $contextTenant] = createUserWithTenant(tenant: $contextTenant, role: 'owner');
$this->actingAs($user);
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-restore',
'name' => 'Restore Tenant',
'status' => 'active',
'workspace_id' => $contextTenant->workspace_id,
]);
$tenant->delete();
$contextTenant = Tenant::create([
'tenant_id' => 'tenant-restore-context',
'name' => 'Restore Context Tenant',
]);
$user = User::factory()->create();
$this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
$contextTenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($contextTenant, true);
Livewire::test(ListTenants::class)
->set('tableFilters.trashed.value', 1)
$this->withSession([
\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $contextTenant->workspace_id,
]);
session([
\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $contextTenant->workspace_id,
]);
Livewire::actingAs($user)
->test(ListTenants::class)
->filterTable('trashed', false)
->callTableAction('restore', $tenant);
$this->assertDatabaseHas('tenants', [

View File

@ -4,7 +4,6 @@
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
use App\Models\InventoryItem;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Auth\UiTooltips;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Http;
@ -18,6 +17,8 @@
test('inventory items are listed for the active tenant', function () {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$otherTenant = Tenant::factory()->create();
InventoryItem::factory()->create([
@ -36,12 +37,6 @@
'platform' => 'windows',
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
$otherTenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user)
->get(InventoryItemResource::getUrl('index', tenant: $tenant))
->assertOk()

View File

@ -6,17 +6,12 @@
use App\Models\InventoryItem;
use App\Models\InventorySyncRun;
use App\Models\Tenant;
use App\Models\User;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
test('inventory hub pages load for a tenant', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),

View File

@ -3,7 +3,6 @@
use App\Filament\Resources\InventorySyncRunResource;
use App\Models\InventorySyncRun;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\Http;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
@ -14,6 +13,8 @@
test('inventory sync runs are listed for the active tenant', function () {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$otherTenant = Tenant::factory()->create();
InventorySyncRun::factory()->create([
@ -28,12 +29,6 @@
'status' => InventorySyncRun::STATUS_SUCCESS,
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
$otherTenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user)
->get(InventorySyncRunResource::getUrl('index', tenant: $tenant))
->assertOk()

View File

@ -5,20 +5,19 @@
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('malformed snapshot renders warning on policy and version detail', function () {
$tenant = Tenant::create([
$tenant = Tenant::factory()->create([
'tenant_id' => 'local-tenant',
'name' => 'Tenant One',
'metadata' => [],
'is_current' => true,
'status' => 'active',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent();
$policy = Policy::create([
@ -40,11 +39,6 @@
'snapshot' => ['a', 'b'], // list-based snapshot should trigger warning
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$policyResponse = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));

View File

@ -6,7 +6,6 @@
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\RestoreService;
@ -49,13 +48,13 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
}
});
$tenant = Tenant::create([
$tenant = Tenant::factory()->create([
'tenant_id' => 'local-tenant',
'name' => 'Tenant One',
'metadata' => [],
'is_current' => true,
'status' => 'active',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent();
$policy = Policy::create([
@ -99,11 +98,6 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
'payload' => $snapshot,
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$detailResponse = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));

View File

@ -30,8 +30,8 @@
]);
$user = User::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
$otherTenant->getKey() => ['role' => 'owner'],
]);

View File

@ -11,14 +11,7 @@
uses(RefreshDatabase::class);
test('policy detail shows normalized settings section', function () {
$tenant = Tenant::create([
'tenant_id' => 'local-tenant',
'name' => 'Tenant One',
'metadata' => [],
'is_current' => true,
]);
$tenant->makeCurrent();
$tenant = Tenant::factory()->create();
$policy = Policy::create([
'tenant_id' => $tenant->id,
@ -49,9 +42,7 @@
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
$response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));

View File

@ -11,14 +11,7 @@
uses(RefreshDatabase::class);
test('policy settings standard view renders array values without crashing', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-arrays',
'name' => 'Tenant Arrays',
'metadata' => [],
'is_current' => true,
]);
$tenant->makeCurrent();
$tenant = Tenant::factory()->create();
$policy = Policy::create([
'tenant_id' => $tenant->id,
@ -48,9 +41,7 @@
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
$response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant).'?tab=settings');

View File

@ -11,14 +11,7 @@
uses(RefreshDatabase::class);
test('policy version detail renders tabs and scroll-safe blocks', function () {
$tenant = Tenant::create([
'tenant_id' => 'local-tenant',
'name' => 'Tenant One',
'metadata' => [],
'is_current' => true,
]);
$tenant->makeCurrent();
$tenant = Tenant::factory()->create();
$policy = Policy::create([
'tenant_id' => $tenant->id,
@ -58,9 +51,7 @@
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
$response = $this->actingAs($user)
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));

View File

@ -4,33 +4,30 @@
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('policy version view shows scope tags even when assignments are missing', function () {
$tenant = Tenant::create([
'tenant_id' => 'local-tenant',
$tenant = Tenant::factory()->create([
'name' => 'Tenant One',
'metadata' => [],
'is_current' => true,
'status' => 'active',
]);
$tenant->makeCurrent();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$policy = Policy::create([
'tenant_id' => $tenant->id,
$policy = Policy::factory()->create([
'tenant_id' => $tenant->getKey(),
'external_id' => 'policy-1',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Policy A',
'platform' => 'windows',
]);
$version = PolicyVersion::create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
$version = PolicyVersion::factory()->create([
'tenant_id' => $tenant->getKey(),
'policy_id' => $policy->getKey(),
'version_number' => 1,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
@ -46,11 +43,6 @@
],
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user)
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));

View File

@ -4,33 +4,30 @@
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('policy version detail shows raw and normalized settings', function () {
$tenant = Tenant::create([
'tenant_id' => 'local-tenant',
$tenant = Tenant::factory()->create([
'name' => 'Tenant One',
'metadata' => [],
'is_current' => true,
'status' => 'active',
]);
$tenant->makeCurrent();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$policy = Policy::create([
'tenant_id' => $tenant->id,
$policy = Policy::factory()->create([
'tenant_id' => $tenant->getKey(),
'external_id' => 'policy-1',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Policy A',
'platform' => 'windows',
]);
$version = PolicyVersion::create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
$version = PolicyVersion::factory()->create([
'tenant_id' => $tenant->getKey(),
'policy_id' => $policy->getKey(),
'version_number' => 1,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
@ -44,11 +41,6 @@
],
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user)
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
@ -61,26 +53,25 @@
});
test('policy version detail shows enrollment notification template settings', function () {
$tenant = Tenant::create([
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-enrollment-notify',
'name' => 'Tenant Enrollment Notify',
'metadata' => [],
'is_current' => true,
'status' => 'active',
]);
$tenant->makeCurrent();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$policy = Policy::create([
'tenant_id' => $tenant->id,
$policy = Policy::factory()->create([
'tenant_id' => $tenant->getKey(),
'external_id' => 'enroll-notify-1',
'policy_type' => 'deviceEnrollmentNotificationConfiguration',
'display_name' => 'Enrollment Notifications',
'platform' => 'all',
]);
$version = PolicyVersion::create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
$version = PolicyVersion::factory()->create([
'tenant_id' => $tenant->getKey(),
'policy_id' => $policy->getKey(),
'version_number' => 1,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
@ -134,11 +125,6 @@
],
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user)
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant).'?tab=normalized-settings');

View File

@ -10,13 +10,7 @@
uses(RefreshDatabase::class);
test('policy versions render with timeline data', function () {
$tenant = Tenant::create([
'tenant_id' => 'local-tenant',
'name' => 'Tenant One',
'metadata' => [],
]);
$tenant->makeCurrent();
$tenant = Tenant::factory()->create();
$policy = Policy::create([
'tenant_id' => $tenant->id,
@ -31,9 +25,7 @@
$service->captureVersion($policy, ['value' => 2], 'tester');
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
$this->actingAs($user)
->get(route('filament.admin.resources.policy-versions.index', filamentTenantRouteParams($tenant)))

View File

@ -5,20 +5,19 @@
use App\Models\PolicyVersion;
use App\Models\SettingsCatalogDefinition;
use App\Models\Tenant;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('shows Settings tab for Settings Catalog policy', function () {
$tenant = Tenant::create([
$tenant = Tenant::factory()->create([
'tenant_id' => 'local-tenant',
'name' => 'Test Tenant',
'metadata' => [],
'is_current' => true,
'status' => 'active',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent();
$policy = Policy::create([
@ -70,11 +69,6 @@
],
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
@ -87,13 +81,13 @@
});
it('shows display names instead of definition IDs', function () {
$tenant = Tenant::create([
$tenant = Tenant::factory()->create([
'tenant_id' => 'local-tenant',
'name' => 'Test Tenant',
'metadata' => [],
'is_current' => true,
'status' => 'active',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent();
$policy = Policy::create([
@ -132,11 +126,6 @@
],
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
@ -146,13 +135,13 @@
})->skip('Manual UI verification required');
it('shows fallback prettified labels when definitions not cached', function () {
$tenant = Tenant::create([
$tenant = Tenant::factory()->create([
'tenant_id' => 'local-tenant',
'name' => 'Test Tenant',
'metadata' => [],
'is_current' => true,
'status' => 'active',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent();
$policy = Policy::create([
@ -186,11 +175,6 @@
],
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
@ -200,13 +184,13 @@
})->skip('Manual UI verification required');
it('shows tabbed layout for non-Settings Catalog policies', function () {
$tenant = Tenant::create([
$tenant = Tenant::factory()->create([
'tenant_id' => 'local-tenant',
'name' => 'Test Tenant',
'metadata' => [],
'is_current' => true,
'status' => 'active',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent();
$policy = Policy::create([
@ -233,11 +217,6 @@
],
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
@ -249,11 +228,13 @@
// T034: Test display names shown (not definition IDs)
it('displays setting display names instead of raw definition IDs', function () {
$tenant = Tenant::create([
$tenant = Tenant::factory()->create([
'tenant_id' => 'local-tenant',
'name' => 'Test Tenant',
'is_current' => true,
'status' => 'active',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent();
SettingsCatalogDefinition::create([
@ -292,10 +273,6 @@
],
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
@ -306,11 +283,13 @@
// T035: Test values formatted correctly
it('formats setting values correctly based on type', function () {
$tenant = Tenant::create([
$tenant = Tenant::factory()->create([
'tenant_id' => 'local-tenant',
'name' => 'Test Tenant',
'is_current' => true,
'status' => 'active',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent();
SettingsCatalogDefinition::create([
@ -370,10 +349,6 @@
],
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
@ -383,11 +358,13 @@
// T036: Test search/filter functionality
it('search filters settings in real-time', function () {
$tenant = Tenant::create([
$tenant = Tenant::factory()->create([
'tenant_id' => 'local-tenant',
'name' => 'Test Tenant',
'is_current' => true,
'status' => 'active',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent();
SettingsCatalogDefinition::create([
@ -436,10 +413,6 @@
],
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
@ -449,11 +422,13 @@
// T037: Test graceful degradation for missing definitions
it('shows prettified fallback labels when definitions are not cached', function () {
$tenant = Tenant::create([
$tenant = Tenant::factory()->create([
'tenant_id' => 'local-tenant',
'name' => 'Test Tenant',
'is_current' => true,
'status' => 'active',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent();
$policy = Policy::create([
@ -485,10 +460,6 @@
],
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));

View File

@ -3,7 +3,6 @@
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
@ -13,10 +12,7 @@
putenv('INTUNE_TENANT_ID=');
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$this->actingAs($user);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent();
@ -80,10 +76,7 @@
]);
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$this->actingAs($user);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent();
@ -148,10 +141,7 @@
]);
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$this->actingAs($user);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent();

View File

@ -2,7 +2,6 @@
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\BackupService;
@ -104,10 +103,7 @@ public function request(string $method, string $path, array $options = []): Grap
metadata: ['source' => 'test', 'backup_set_id' => $backupSet->id],
);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$response = $this
->actingAs($user)
@ -147,10 +143,7 @@ public function request(string $method, string $path, array $options = []): Grap
$versions = app(VersionService::class);
$versions->captureFromGraph($tenant, $policy, createdBy: 'tester@example.com');
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$response = $this
->actingAs($user)

View File

@ -5,7 +5,6 @@
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -89,10 +88,7 @@
],
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$policyResponse = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));

View File

@ -3,7 +3,6 @@
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\PolicySyncService;
@ -110,10 +109,7 @@ public function request(string $method, string $path, array $options = []): Grap
],
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$response = $this
->actingAs($user)

View File

@ -4,7 +4,6 @@
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\RestoreService;
@ -146,10 +145,7 @@ public function request(string $method, string $path, array $options = []): Grap
'payload' => $payload,
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$this->actingAs($user);
$service = app(RestoreService::class);

View File

@ -161,10 +161,7 @@ public function request(string $method, string $path, array $options = []): Grap
'payload' => $payload,
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$this->actingAs($user);
$service = app(RestoreService::class);

View File

@ -5,7 +5,6 @@
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -55,10 +54,7 @@
],
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$policyResponse = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant).'?tab=settings');

View File

@ -32,7 +32,7 @@
assertNoOutboundHttp(function () use ($tenant): void {
$this->get(TenantDashboard::getUrl(tenant: $tenant))
->assertOk()
->assertSee("/admin/t/{$tenant->external_id}/workspaces", false)
->assertSee('/admin/workspaces', false)
->assertSee('Needs Attention')
->assertSee('Recent Operations')
->assertSee('Recent Drift Findings');

View File

@ -3,7 +3,6 @@
use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Jobs\BulkTenantSyncJob;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Auth\UiTooltips;
use Filament\Events\TenantSet;
use Filament\Facades\Filament;
@ -28,15 +27,11 @@
});
test('tenant portfolio tenant view returns 404 for non-member tenant record', function () {
$user = User::factory()->create();
$this->actingAs($user);
$authorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-authorized-view']);
$unauthorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-unauthorized-view']);
$user->tenants()->syncWithoutDetaching([
$authorizedTenant->getKey() => ['role' => 'owner'],
]);
[$user, $authorizedTenant] = createUserWithTenant($authorizedTenant, role: 'owner');
$this->actingAs($user);
$this->get(route('filament.admin.resources.tenants.view', array_merge(
filamentTenantRouteParams($unauthorizedTenant),
@ -45,15 +40,11 @@
});
test('tenant portfolio tenant edit returns 404 for non-member tenant record', function () {
$user = User::factory()->create();
$this->actingAs($user);
$authorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-authorized-edit']);
$unauthorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-unauthorized-edit']);
$user->tenants()->syncWithoutDetaching([
$authorizedTenant->getKey() => ['role' => 'owner'],
]);
[$user, $authorizedTenant] = createUserWithTenant($authorizedTenant, role: 'owner');
$this->actingAs($user);
$this->get(route('filament.admin.resources.tenants.edit', array_merge(
filamentTenantRouteParams($unauthorizedTenant),
@ -62,9 +53,6 @@
});
test('tenant portfolio lists only tenants the user can access', function () {
$user = User::factory()->create();
$this->actingAs($user);
$authorizedTenant = Tenant::factory()->create([
'tenant_id' => 'tenant-portfolio-authorized',
'name' => 'Authorized Tenant',
@ -75,9 +63,8 @@
'name' => 'Unauthorized Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$authorizedTenant->getKey() => ['role' => 'owner'],
]);
[$user, $authorizedTenant] = createUserWithTenant($authorizedTenant, role: 'owner');
$this->actingAs($user);
$this->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($authorizedTenant)))
->assertOk()
@ -88,14 +75,16 @@
test('tenant portfolio bulk sync dispatches one job per eligible tenant', function () {
Bus::fake();
$user = User::factory()->create();
$tenantA = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-a']);
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
$this->actingAs($user);
$tenantA = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-a']);
$tenantB = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-b']);
$tenantB = Tenant::factory()->create([
'tenant_id' => 'tenant-bulk-b',
'workspace_id' => (int) $tenantA->workspace_id,
]);
$user->tenants()->syncWithoutDetaching([
$tenantA->getKey() => ['role' => 'owner'],
$tenantB->getKey() => ['role' => 'operator'],
]);
@ -118,14 +107,9 @@
test('tenant portfolio bulk sync is disabled for readonly users', function () {
Bus::fake();
$user = User::factory()->create();
$this->actingAs($user);
$tenant = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-readonly']);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'readonly'],
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly');
$this->actingAs($user);
Filament::setTenant($tenant, true);
@ -146,14 +130,16 @@
test('tenant portfolio bulk sync is disabled when selection includes unauthorized tenants', function () {
Bus::fake();
$user = User::factory()->create();
$tenantA = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-mixed-a']);
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
$this->actingAs($user);
$tenantA = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-mixed-a']);
$tenantB = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-mixed-b']);
$tenantB = Tenant::factory()->create([
'tenant_id' => 'tenant-bulk-mixed-b',
'workspace_id' => (int) $tenantA->workspace_id,
]);
$user->tenants()->syncWithoutDetaching([
$tenantA->getKey() => ['role' => 'owner'],
$tenantB->getKey() => ['role' => 'readonly'],
]);

View File

@ -3,7 +3,6 @@
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use App\Http\Controllers\RbacDelegatedAuthController;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use Filament\Facades\Filament;
@ -31,11 +30,8 @@ function tenantWithApp(): Tenant
test('rbac action prompts login when no delegated token', function () {
$tenant = tenantWithApp();
$user = User::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
@ -54,11 +50,8 @@ function tenantWithApp(): Tenant
test('rbac action succeeds and clears token cache', function () {
$tenant = tenantWithApp();
$user = User::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
@ -162,11 +155,8 @@ public function request(string $method, string $path, array $options = []): Grap
test('rbac action is idempotent on rerun', function () {
$tenant = tenantWithApp();
$user = User::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
@ -276,11 +266,8 @@ public function request(string $method, string $path, array $options = []): Grap
test('existing group membership error from Graph json payload is treated idempotently', function () {
$tenant = tenantWithApp();
$user = User::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
@ -380,11 +367,8 @@ public function request(string $method, string $path, array $options = []): Grap
test('group picker is disabled without delegated token', function () {
$tenant = tenantWithApp();
$user = User::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
@ -399,11 +383,8 @@ public function request(string $method, string $path, array $options = []): Grap
test('group picker toggles when switching modes', function () {
$tenant = tenantWithApp();
$user = User::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
@ -417,11 +398,8 @@ public function request(string $method, string $path, array $options = []): Grap
test('delegated group search returns options and persists selection', function () {
$tenant = tenantWithApp();
$user = User::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
@ -532,11 +510,8 @@ public function request(string $method, string $path, array $options = []): Grap
test('delegated role search returns options and persists role definition id', function () {
$tenant = tenantWithApp();
$user = User::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);

View File

@ -59,9 +59,8 @@ public function request(string $method, string $path, array $options = []): Grap
'tenant_id' => 'tenant-context',
'name' => 'Context Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$contextTenant->getKey() => ['role' => 'owner'],
]);
[$user, $contextTenant] = createUserWithTenant($contextTenant, $user, role: 'owner');
$this->actingAs($user);
Filament::setTenant($contextTenant, true);
Livewire::test(CreateTenant::class)
@ -134,16 +133,15 @@ public function request(string $method, string $path, array $options = []): Grap
});
$user = User::factory()->create();
$this->actingAs($user);
$tenant = Tenant::create([
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-error',
'name' => 'Error Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
'status' => 'active',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
@ -173,9 +171,8 @@ public function request(string $method, string $path, array $options = []): Grap
'tenant_id' => 'tenant-ui',
'name' => 'UI Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
[$user, $tenant] = createUserWithTenant($tenant, $user, role: 'owner');
$this->actingAs($user);
config(['intune_permissions.granted_stub' => []]);
@ -207,9 +204,8 @@ public function request(string $method, string $path, array $options = []): Grap
'app_client_id' => 'client-123',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
[$user, $tenant] = createUserWithTenant($tenant, $user, role: 'owner');
$this->actingAs($user);
$response = $this->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant)));
@ -225,9 +221,9 @@ public function request(string $method, string $path, array $options = []): Grap
'tenant_id' => 'tenant-ui-deactivate',
'name' => 'UI Tenant Deactivate',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
[$user, $tenant] = createUserWithTenant($tenant, $user, role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);

View File

@ -4,7 +4,6 @@
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -47,10 +46,7 @@
],
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));

View File

@ -5,7 +5,6 @@
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
@ -46,16 +45,11 @@
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspaceA->getKey());
$user->forceFill(['last_workspace_id' => (int) $workspaceA->getKey()])->save();
Filament::setTenant(null, true);
$this->actingAs($user)
->get(route('filament.admin.pages.choose-tenant'))
->assertOk();
expect(Filament::getTenant())
->toBeInstanceOf(Tenant::class)
->and(Filament::getTenant()?->getKey())
->toBe($tenantA->getKey());
->assertOk()
->assertSee($tenantA->name)
->assertDontSee($tenantB->name);
});
test('user menu renders a workspace switcher when a workspace is selected', function () {

View File

@ -0,0 +1,874 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\TenantMembershipManager;
use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Support\Auth\Capabilities;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Gate;
use Livewire\Livewire;
it('returns 404 for non-members when starting onboarding', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
$this->actingAs($user)
->get("/admin/w/{$workspace->getKey()}/managed-tenants/onboarding")
->assertNotFound();
});
it('returns 403 for workspace members without onboarding capability', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'readonly',
]);
$this->actingAs($user)
->get("/admin/w/{$workspace->getKey()}/managed-tenants/onboarding")
->assertForbidden();
});
it('renders onboarding wizard for workspace owners', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)
->get("/admin/w/{$workspace->getKey()}/managed-tenants/onboarding")
->assertSuccessful();
});
it('allows owners to identify a managed tenant and creates a pending tenant + session', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
$tenantGuid = '11111111-1111-1111-1111-111111111111';
Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace])
->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
expect((int) $tenant->workspace_id)->toBe((int) $workspace->getKey());
expect($tenant->status)->toBe('pending');
$this->assertDatabaseHas('managed_tenant_onboarding_sessions', [
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'current_step' => 'identify',
]);
$this->assertDatabaseHas('tenant_memberships', [
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
expect(
(int) \App\Models\TenantMembership::query()
->where('tenant_id', $tenant->getKey())
->where('role', 'owner')
->count()
)->toBe(1);
});
it('upgrades the initiating user to owner if they already have a lower tenant role', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$tenantGuid = '66666666-6666-6666-6666-666666666666';
$tenant = Tenant::factory()->create([
'workspace_id' => $workspace->getKey(),
'tenant_id' => $tenantGuid,
'name' => 'Acme',
'status' => 'pending',
]);
\App\Models\TenantMembership::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'readonly',
'source' => 'manual',
'created_by_user_id' => (int) $user->getKey(),
]);
$this->actingAs($user);
Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace])
->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$membership = \App\Models\TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('user_id', (int) $user->getKey())
->firstOrFail();
expect($membership->role)->toBe('owner');
expect(\App\Models\TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('user_id', (int) $user->getKey())
->count())->toBe(1);
});
it('writes audit logs for onboarding start and resume', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
$tenantGuid = '44444444-4444-4444-4444-444444444444';
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$this->assertDatabaseHas('audit_logs', [
'workspace_id' => (int) $workspace->getKey(),
'actor_id' => (int) $user->getKey(),
'action' => 'managed_tenant_onboarding.start',
'resource_type' => 'tenant',
'resource_id' => (string) $tenant->getKey(),
'status' => 'success',
]);
$this->assertDatabaseHas('audit_logs', [
'workspace_id' => (int) $workspace->getKey(),
'actor_id' => (int) $user->getKey(),
'action' => 'managed_tenant_onboarding.resume',
'resource_type' => 'tenant',
'resource_id' => (string) $tenant->getKey(),
'status' => 'success',
]);
expect(AuditLog::query()
->where('workspace_id', (int) $workspace->getKey())
->where('resource_type', 'tenant')
->where('resource_id', (string) $tenant->getKey())
->whereIn('action', ['managed_tenant_onboarding.start', 'managed_tenant_onboarding.resume'])
->count())->toBeGreaterThanOrEqual(2);
});
it('blocks demoting or removing the last remaining tenant owner', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
$tenantGuid = '55555555-5555-5555-5555-555555555555';
Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace])
->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$membership = \App\Models\TenantMembership::query()
->where('tenant_id', $tenant->getKey())
->where('user_id', $user->getKey())
->firstOrFail();
expect(fn () => app(TenantMembershipManager::class)->changeRole($tenant, $user, $membership, 'manager'))
->toThrow(DomainException::class, 'You cannot demote the last remaining owner.');
expect(fn () => app(TenantMembershipManager::class)->removeMember($tenant, $user, $membership))
->toThrow(DomainException::class, 'You cannot remove the last remaining owner.');
});
it('returns 404 for legacy onboarding entry points', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$this->get('/admin/register-tenant')->assertNotFound();
$this->get('/admin/managed-tenants')->assertNotFound();
$this->get('/admin/managed-tenants/onboarding')->assertNotFound();
$this->get('/admin/new')->assertNotFound();
});
it('is idempotent when identifying the same managed tenant twice', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
$tenantGuid = '22222222-2222-2222-2222-222222222222';
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
expect(Tenant::query()->where('tenant_id', $tenantGuid)->count())->toBe(1);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
expect(TenantOnboardingSession::query()
->where('workspace_id', $workspace->getKey())
->where('tenant_id', $tenant->getKey())
->count())->toBe(1);
$this->assertDatabaseHas('managed_tenant_onboarding_sessions', [
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
]);
});
it('returns 404 and does not create anything when tenant_id exists in another workspace', function (): void {
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspaceB->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$tenantGuid = '33333333-3333-3333-3333-333333333333';
Tenant::factory()->create([
'workspace_id' => $workspaceA->getKey(),
'tenant_id' => $tenantGuid,
'name' => 'Acme',
'status' => 'active',
]);
$this->actingAs($user);
Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspaceB])
->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme'])
->assertStatus(404);
expect(Tenant::query()->where('tenant_id', $tenantGuid)->count())->toBe(1);
expect(TenantOnboardingSession::query()
->where('workspace_id', $workspaceB->getKey())
->whereIn('tenant_id', Tenant::query()->where('tenant_id', $tenantGuid)->pluck('id'))
->count())->toBe(0);
});
it('binds an unscoped existing tenant to the current workspace when safe and allows identifying it', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$tenantGuid = '77777777-7777-7777-7777-777777777777';
$tenant = Tenant::factory()->create([
'workspace_id' => null,
'tenant_id' => $tenantGuid,
'name' => 'Acme',
'status' => 'active',
]);
\App\Models\TenantMembership::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
'source' => 'manual',
'created_by_user_id' => (int) $user->getKey(),
]);
$this->assertDatabaseHas('tenants', [
'id' => (int) $tenant->getKey(),
'workspace_id' => null,
'tenant_id' => $tenantGuid,
]);
$this->assertDatabaseHas('tenant_memberships', [
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
]);
$this->actingAs($user);
Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace])
->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme'])
->assertOk();
$this->assertDatabaseHas('tenants', [
'id' => (int) $tenant->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => $tenantGuid,
]);
});
it('auto-selects the default provider connection and allows switching', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$tenantGuid = '99999999-9999-9999-9999-999999999999';
$tenant = Tenant::factory()->create([
'workspace_id' => $workspace->getKey(),
'tenant_id' => $tenantGuid,
'name' => 'Acme',
'status' => 'pending',
]);
$default = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $tenantGuid,
'display_name' => 'Default',
'is_default' => true,
]);
$other = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft_alt',
'entra_tenant_id' => $tenantGuid,
'display_name' => 'Other',
'is_default' => false,
]);
$this->actingAs($user);
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$component->assertSet('selectedProviderConnectionId', (int) $default->getKey());
$session = TenantOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('tenant_id', (int) $tenant->getKey())
->firstOrFail();
expect($session->state['provider_connection_id'] ?? null)->toBe((int) $default->getKey());
$component->call('selectProviderConnection', (int) $other->getKey());
$component->assertSet('selectedProviderConnectionId', (int) $other->getKey());
$session->refresh();
expect($session->state['provider_connection_id'] ?? null)->toBe((int) $other->getKey());
});
it('dedupes verification runs: starting verification twice returns the active run and dispatches only once', function (): void {
Bus::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
$tenantGuid = '77777777-7777-7777-7777-777777777777';
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'is_default' => true,
]);
$component->set('selectedProviderConnectionId', (int) $connection->getKey());
$component->call('startVerification');
$component->call('startVerification');
expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'provider.connection.check')
->count())->toBe(1);
Bus::assertDispatchedTimes(\App\Jobs\ProviderConnectionHealthCheckJob::class, 1);
$this->assertDatabaseHas('audit_logs', [
'workspace_id' => (int) $workspace->getKey(),
'actor_id' => (int) $user->getKey(),
'action' => 'managed_tenant_onboarding.verification_start',
'resource_type' => 'operation_run',
]);
$session = TenantOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('tenant_id', (int) $tenant->getKey())
->firstOrFail();
expect($session->state['verification_operation_run_id'] ?? null)->not->toBeNull();
});
it('creates a provider connection with encrypted credentials and does not persist secrets in session state', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
$tenantGuid = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
$secret = 'super-secret-123';
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component
->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme'])
->call('createProviderConnection', [
'display_name' => 'Onboarding Connection',
'client_id' => 'client-id-1',
'client_secret' => $secret,
'is_default' => true,
])
->assertDontSee($secret);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$connection = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->where('provider', 'microsoft')
->where('entra_tenant_id', $tenantGuid)
->firstOrFail();
expect($connection->credential)->toBeInstanceOf(ProviderCredential::class);
expect($connection->credential->toArray())->not->toHaveKey('payload');
$session = TenantOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('tenant_id', (int) $tenant->getKey())
->firstOrFail();
$state = $session->state ?? [];
expect($state)->not->toHaveKey('client_id');
expect($state)->not->toHaveKey('client_secret');
expect($state['provider_connection_id'] ?? null)->toBe((int) $connection->getKey());
});
it('starts verification, creates an operation run, dispatches the job, and does not include secrets in run context', function (): void {
Bus::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
$tenantGuid = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $tenantGuid,
'is_default' => true,
]);
$component->set('selectedProviderConnectionId', (int) $connection->getKey());
$component->call('startVerification');
$run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'provider.connection.check')
->latest('id')
->firstOrFail();
expect($run->context)->toBeArray();
expect($run->context['provider_connection_id'] ?? null)->toBe((int) $connection->getKey());
expect($run->context)->not->toHaveKey('client_id');
expect($run->context)->not->toHaveKey('client_secret');
Bus::assertDispatched(\App\Jobs\ProviderConnectionHealthCheckJob::class);
});
it('can resume the latest onboarding session as a different authorized workspace member', function (): void {
Bus::fake();
$workspace = Workspace::factory()->create();
$initiator = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $initiator->getKey(),
'role' => 'owner',
]);
$resumer = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $resumer->getKey(),
'role' => 'manager',
]);
$tenantGuid = 'cccccccc-cccc-cccc-cccc-cccccccccccc';
$this->actingAs($initiator);
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $tenantGuid,
'is_default' => true,
]);
$session = TenantOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('tenant_id', (int) $tenant->getKey())
->firstOrFail();
$session->update([
'state' => array_merge($session->state ?? [], [
'provider_connection_id' => (int) $connection->getKey(),
]),
]);
$this->actingAs($resumer);
Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace])
->call('startVerification');
Bus::assertDispatched(\App\Jobs\ProviderConnectionHealthCheckJob::class);
});
it('completes onboarding only after verification succeeded and redirects to tenant dashboard', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
$tenantGuid = 'dddddddd-dddd-dddd-dddd-dddddddddddd';
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $tenantGuid,
'is_default' => true,
]);
$run = OperationRun::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'succeeded',
'run_identity_hash' => sha1('verify-ok-'.(string) $connection->getKey()),
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$session = TenantOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('tenant_id', (int) $tenant->getKey())
->firstOrFail();
$session->update([
'state' => array_merge($session->state ?? [], [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
]),
]);
$component
->call('completeOnboarding')
->assertRedirect(TenantDashboard::getUrl(tenant: $tenant));
$tenant->refresh();
expect($tenant->status)->toBe('active');
$session->refresh();
expect($session->completed_at)->not->toBeNull();
});
it('starts selected bootstrap actions as separate operation runs and dispatches their jobs', function (): void {
Bus::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
$tenantGuid = 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee';
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $tenantGuid,
'is_default' => true,
]);
$verificationRun = OperationRun::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'succeeded',
'run_identity_hash' => sha1('verify-ok-bootstrap-'.(string) $connection->getKey()),
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$session = TenantOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('tenant_id', (int) $tenant->getKey())
->firstOrFail();
$session->update([
'state' => array_merge($session->state ?? [], [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $verificationRun->getKey(),
]),
]);
$component->call('startBootstrap', ['inventory.sync', 'compliance.snapshot']);
Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1);
Bus::assertDispatchedTimes(\App\Jobs\ProviderComplianceSnapshotJob::class, 1);
expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->whereIn('type', ['inventory.sync', 'compliance.snapshot'])
->count())->toBe(2);
$session->refresh();
$runs = $session->state['bootstrap_operation_runs'] ?? [];
expect($runs)->toBeArray();
expect($runs['inventory.sync'] ?? null)->toBeInt();
expect($runs['compliance.snapshot'] ?? null)->toBeInt();
});
it('returns scope-busy semantics for verification when another run is active for the same connection scope', function (): void {
Bus::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
$tenantGuid = '88888888-8888-8888-8888-888888888888';
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'is_default' => true,
]);
OperationRun::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'inventory.sync',
'status' => 'queued',
'outcome' => 'pending',
'run_identity_hash' => sha1('busy-'.(string) $connection->getKey()),
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$component->set('selectedProviderConnectionId', (int) $connection->getKey());
$component->call('startVerification');
expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'provider.connection.check')
->count())->toBe(0);
Bus::assertNotDispatched(\App\Jobs\ProviderConnectionHealthCheckJob::class);
});
it('registers the onboarding capability in the canonical registry', function (): void {
expect(Capabilities::isKnown(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD))->toBeTrue();
});
it('maps onboarding capability to owner and manager workspace roles', function (): void {
expect(WorkspaceRoleCapabilityMap::hasCapability('owner', Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD))->toBeTrue();
expect(WorkspaceRoleCapabilityMap::hasCapability('manager', Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD))->toBeTrue();
expect(WorkspaceRoleCapabilityMap::hasCapability('operator', Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD))->toBeFalse();
expect(WorkspaceRoleCapabilityMap::hasCapability('readonly', Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD))->toBeFalse();
});
it('authorizes onboarding via Gate for owner and manager memberships', function (): void {
$workspace = Workspace::factory()->create();
$owner = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $owner->getKey(),
'role' => 'owner',
]);
$manager = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $manager->getKey(),
'role' => 'manager',
]);
$readonly = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $readonly->getKey(),
'role' => 'readonly',
]);
expect(Gate::forUser($owner)->allows(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD, $workspace))->toBeTrue();
expect(Gate::forUser($manager)->allows(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD, $workspace))->toBeTrue();
expect(Gate::forUser($readonly)->allows(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD, $workspace))->toBeFalse();
});
it('keeps filament tenant routing key stable (external_id resolves /admin/t/{tenant})', function (): void {
[$user, $tenant] = createUserWithTenant(
Tenant::factory()->create([
'workspace_id' => null,
'tenant_id' => '11111111-1111-1111-1111-111111111111',
]),
role: 'owner',
);
$this->actingAs($user)
->get(TenantDashboard::getUrl(tenant: $tenant))
->assertSuccessful();
$tenant->refresh();
expect($tenant->external_id)->toBe($tenant->tenant_id);
});
it('can persist a tenant onboarding session row', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$session = TenantOnboardingSession::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'current_step' => 'identify',
'state' => ['example' => 'value'],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
expect($session->exists)->toBeTrue();
$this->assertDatabaseHas('managed_tenant_onboarding_sessions', [
'id' => $session->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'current_step' => 'identify',
]);
});

View File

@ -4,7 +4,7 @@
use App\Models\Tenant;
it('redirects /admin/new to the canonical managed-tenant onboarding page', function (): void {
it('does not provide legacy onboarding entry points under /admin/new', function (): void {
$tenant = Tenant::factory()->create();
[$user] = createUserWithTenant($tenant, role: 'owner');
@ -13,5 +13,5 @@
$this->actingAs($user)
->get('/admin/new')
->assertRedirect('/admin/w/'.$workspace->slug.'/managed-tenants/onboarding');
->assertNotFound();
});

View File

@ -8,8 +8,7 @@
it('allows access to monitoring page for tenant members', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$tenant->users()->attach($user, ['role' => 'owner']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$run = OperationRun::create([
'tenant_id' => $tenant->id,
@ -28,8 +27,7 @@
it('renders monitoring pages DB-only (never calls Graph)', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$tenant->users()->attach($user, ['role' => 'owner']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$run = OperationRun::create([
'tenant_id' => $tenant->id,
@ -61,8 +59,7 @@
it('shows runs only for current tenant', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$user = User::factory()->create();
$tenantA->users()->attach($user, ['role' => 'owner']);
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
// We must simulate being in tenant context
$this->actingAs($user);
@ -99,8 +96,7 @@
it('allows readonly users to view operations list and detail', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$tenant->users()->attach($user, ['role' => 'readonly']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly');
$run = OperationRun::create([
'tenant_id' => $tenant->id,

View File

@ -2,14 +2,12 @@
use App\Filament\Resources\OperationRunResource;
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use Illuminate\Notifications\DatabaseNotification;
it('sanitizes persisted run failures and terminal notifications', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$tenant->users()->attach($user, ['role' => 'owner']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);

View File

@ -14,9 +14,7 @@
'tenant_id' => $this->tenant->id,
]);
$this->user = User::factory()->create();
$this->user->tenants()->syncWithoutDetaching([
$this->tenant->getKey() => ['role' => 'owner'],
]);
[$this->user, $this->tenant] = createUserWithTenant($this->tenant, $this->user, role: 'owner');
});
it('displays policy version page', function () {

View File

@ -3,7 +3,6 @@
use App\Filament\Resources\OperationRunResource;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
@ -25,9 +24,10 @@
'outcome' => 'pending',
]);
$user = User::factory()->create();
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
$tenantB->forceFill(['workspace_id' => (int) $tenantA->workspace_id])->save();
$user->tenants()->syncWithoutDetaching([
$tenantA->getKey() => ['role' => 'owner'],
$tenantB->getKey() => ['role' => 'owner'],
]);
@ -49,9 +49,10 @@
'outcome' => 'pending',
]);
$user = User::factory()->create();
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
$tenantB->forceFill(['workspace_id' => (int) $tenantA->workspace_id])->save();
$user->tenants()->syncWithoutDetaching([
$tenantA->getKey() => ['role' => 'owner'],
$tenantB->getKey() => ['role' => 'owner'],
]);
@ -70,10 +71,7 @@
'outcome' => 'pending',
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'readonly'],
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly');
$this->actingAs($user)
->get(OperationRunResource::getUrl('index', tenant: $tenant))

View File

@ -17,7 +17,7 @@
Http::preventStrayRequests();
});
it('redirects legacy managed-tenants entry to workspace landing when workspace is selected', function (): void {
it('returns 404 for legacy managed-tenants entry even when workspace is selected', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['slug' => 'acme']);
@ -30,7 +30,7 @@
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/managed-tenants')
->assertRedirect("/admin/w/{$workspace->slug}/managed-tenants");
->assertNotFound();
});
it('keeps the managed-tenants landing tenantless even if the user has a tenant in another workspace', function (): void {

View File

@ -24,7 +24,7 @@
$this
->actingAs($user)
->post(route('admin.switch-workspace'), ['workspace_id' => (int) $workspace->getKey()])
->assertRedirect(route('filament.admin.tenant.registration'));
->assertRedirect(route('admin.workspace.managed-tenants.onboarding', ['workspace' => $workspace->slug ?? $workspace->getKey()]));
expect(session(WorkspaceContext::SESSION_KEY))->toBe((int) $workspace->getKey());
});

View File

@ -88,11 +88,26 @@ function assertNoOutboundHttp(Closure $callback): mixed
/**
* @return array{0: User, 1: Tenant}
*/
function createUserWithTenant(?Tenant $tenant = null, ?User $user = null, string $role = 'owner'): array
{
function createUserWithTenant(
?Tenant $tenant = null,
?User $user = null,
string $role = 'owner',
?string $workspaceRole = null,
): array {
$user ??= User::factory()->create();
$tenant ??= Tenant::factory()->create();
$workspaceRole ??= $role;
$validWorkspaceRoles = array_map(
static fn (\App\Support\Auth\WorkspaceRole $role): string => $role->value,
\App\Support\Auth\WorkspaceRole::cases(),
);
if (! in_array($workspaceRole, $validWorkspaceRoles, true)) {
$workspaceRole = \App\Support\Auth\WorkspaceRole::Owner->value;
}
$workspace = null;
if ($tenant->workspace_id !== null) {
@ -107,11 +122,11 @@ function createUserWithTenant(?Tenant $tenant = null, ?User $user = null, string
])->save();
}
WorkspaceMembership::query()->firstOrCreate([
WorkspaceMembership::query()->updateOrCreate([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
], [
'role' => 'owner',
'role' => $workspaceRole,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());

View File

@ -79,7 +79,7 @@ function restoreIntuneTenantId(string|false $original): void
'is_current' => false,
]);
expect(fn () => Tenant::current())->toThrow(\RuntimeException::class, 'No current tenant selected.');
expect(fn () => Tenant::currentOrFail())->toThrow(\RuntimeException::class, 'No current tenant selected.');
restoreIntuneTenantId($originalEnv);
});