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:
parent
5f9e6fb04a
commit
b6343d5c3a
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -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.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)
|
- PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 (067-rbac-troubleshooting)
|
||||||
- PostgreSQL (via Laravel Sail) (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)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -33,9 +35,9 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## 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
|
- 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 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 -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
use App\Filament\Pages\Tenancy\RegisterTenant as RegisterTenantPage;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserTenantPreference;
|
use App\Models\UserTenantPreference;
|
||||||
@ -73,11 +72,6 @@ public function selectTenant(int $tenantId): void
|
|||||||
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
|
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function canRegisterTenant(): bool
|
|
||||||
{
|
|
||||||
return RegisterTenantPage::canView();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function persistLastTenant(User $user, Tenant $tenant): void
|
private function persistLastTenant(User $user, Tenant $tenant): void
|
||||||
{
|
{
|
||||||
if (Schema::hasColumn('users', 'last_tenant_id')) {
|
if (Schema::hasColumn('users', 'last_tenant_id')) {
|
||||||
|
|||||||
1244
app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
Normal file
1244
app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
Normal file
File diff suppressed because it is too large
Load Diff
@ -5,7 +5,6 @@
|
|||||||
namespace App\Filament\Pages\Workspaces;
|
namespace App\Filament\Pages\Workspaces;
|
||||||
|
|
||||||
use App\Filament\Pages\ChooseTenant;
|
use App\Filament\Pages\ChooseTenant;
|
||||||
use App\Filament\Pages\Tenancy\RegisterTenant as RegisterTenantPage;
|
|
||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -48,11 +47,6 @@ public function getTenants(): Collection
|
|||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function canRegisterTenant(): bool
|
|
||||||
{
|
|
||||||
return RegisterTenantPage::canView();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function goToChooseTenant(): void
|
public function goToChooseTenant(): void
|
||||||
{
|
{
|
||||||
$this->redirect(ChooseTenant::getUrl());
|
$this->redirect(ChooseTenant::getUrl());
|
||||||
|
|||||||
@ -17,6 +17,8 @@ class WorkspaceResource extends Resource
|
|||||||
{
|
{
|
||||||
protected static ?string $model = Workspace::class;
|
protected static ?string $model = Workspace::class;
|
||||||
|
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
protected static bool $isScopedToTenant = false;
|
protected static bool $isScopedToTenant = false;
|
||||||
|
|
||||||
protected static ?string $recordTitleAttribute = 'name';
|
protected static ?string $recordTitleAttribute = 'name';
|
||||||
|
|||||||
@ -51,7 +51,7 @@ public function __invoke(Request $request): RedirectResponse
|
|||||||
$tenantCount = (int) $tenantsQuery->count();
|
$tenantCount = (int) $tenantsQuery->count();
|
||||||
|
|
||||||
if ($tenantCount === 0) {
|
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) {
|
if ($tenantCount === 1) {
|
||||||
|
|||||||
54
app/Models/TenantOnboardingSession.php
Normal file
54
app/Models/TenantOnboardingSession.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,8 +6,10 @@
|
|||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
use App\Policies\ProviderConnectionPolicy;
|
use App\Policies\ProviderConnectionPolicy;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||||
@ -23,19 +25,36 @@ public function boot(): void
|
|||||||
{
|
{
|
||||||
$this->registerPolicies();
|
$this->registerPolicies();
|
||||||
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
$tenantResolver = app(CapabilityResolver::class);
|
||||||
|
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
$defineTenantCapability = function (string $capability) use ($resolver): void {
|
$defineTenantCapability = function (string $capability) use ($tenantResolver): void {
|
||||||
Gate::define($capability, function (User $user, ?Tenant $tenant = null) use ($resolver, $capability): bool {
|
Gate::define($capability, function (User $user, ?Tenant $tenant = null) use ($tenantResolver, $capability): bool {
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return false;
|
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) {
|
foreach (Capabilities::all() as $capability) {
|
||||||
|
if (str_starts_with($capability, 'workspace')) {
|
||||||
|
$defineWorkspaceCapability($capability);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$defineTenantCapability($capability);
|
$defineTenantCapability($capability);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,8 +6,8 @@
|
|||||||
use App\Filament\Pages\ChooseTenant;
|
use App\Filament\Pages\ChooseTenant;
|
||||||
use App\Filament\Pages\ChooseWorkspace;
|
use App\Filament\Pages\ChooseWorkspace;
|
||||||
use App\Filament\Pages\NoAccess;
|
use App\Filament\Pages\NoAccess;
|
||||||
use App\Filament\Pages\Tenancy\RegisterTenant;
|
|
||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -42,25 +42,20 @@ public function panel(Panel $panel): Panel
|
|||||||
ChooseWorkspace::registerRoutes($panel);
|
ChooseWorkspace::registerRoutes($panel);
|
||||||
ChooseTenant::registerRoutes($panel);
|
ChooseTenant::registerRoutes($panel);
|
||||||
NoAccess::registerRoutes($panel);
|
NoAccess::registerRoutes($panel);
|
||||||
|
|
||||||
|
WorkspaceResource::registerRoutes($panel);
|
||||||
})
|
})
|
||||||
->tenant(Tenant::class, slugAttribute: 'external_id')
|
->tenant(Tenant::class, slugAttribute: 'external_id')
|
||||||
->tenantRoutePrefix('t')
|
->tenantRoutePrefix('t')
|
||||||
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
|
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
|
||||||
->searchableTenantMenu()
|
->searchableTenantMenu()
|
||||||
->tenantRegistration(RegisterTenant::class)
|
|
||||||
->colors([
|
->colors([
|
||||||
'primary' => Color::Amber,
|
'primary' => Color::Amber,
|
||||||
])
|
])
|
||||||
->navigationItems([
|
->navigationItems([
|
||||||
NavigationItem::make('Workspaces')
|
NavigationItem::make('Workspaces')
|
||||||
->url(function (): string {
|
->url(function (): string {
|
||||||
$tenant = Filament::getTenant();
|
return route('filament.admin.resources.workspaces.index');
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
|
||||||
return route('filament.admin.resources.workspaces.index', ['tenant' => $tenant->external_id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ChooseWorkspace::getUrl();
|
|
||||||
})
|
})
|
||||||
->icon('heroicon-o-squares-2x2')
|
->icon('heroicon-o-squares-2x2')
|
||||||
->group('Settings')
|
->group('Settings')
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
|
use App\Support\Audit\AuditContextSanitizer;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
|
||||||
class WorkspaceAuditLogger
|
class WorkspaceAuditLogger
|
||||||
@ -26,6 +27,10 @@ public function log(
|
|||||||
$metadata = $context['metadata'] ?? [];
|
$metadata = $context['metadata'] ?? [];
|
||||||
unset($context['metadata']);
|
unset($context['metadata']);
|
||||||
|
|
||||||
|
$metadata = is_array($metadata) ? $metadata : [];
|
||||||
|
|
||||||
|
$sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context);
|
||||||
|
|
||||||
return AuditLog::create([
|
return AuditLog::create([
|
||||||
'tenant_id' => null,
|
'tenant_id' => null,
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
@ -36,7 +41,7 @@ public function log(
|
|||||||
'resource_type' => $resourceType,
|
'resource_type' => $resourceType,
|
||||||
'resource_id' => $resourceId,
|
'resource_id' => $resourceId,
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'metadata' => $metadata + $context,
|
'metadata' => $sanitizedMetadata,
|
||||||
'recorded_at' => CarbonImmutable::now(),
|
'recorded_at' => CarbonImmutable::now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,12 +23,14 @@ class WorkspaceRoleCapabilityMap
|
|||||||
Capabilities::WORKSPACE_ARCHIVE,
|
Capabilities::WORKSPACE_ARCHIVE,
|
||||||
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
|
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
|
||||||
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
|
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD,
|
||||||
],
|
],
|
||||||
|
|
||||||
WorkspaceRole::Manager->value => [
|
WorkspaceRole::Manager->value => [
|
||||||
Capabilities::WORKSPACE_VIEW,
|
Capabilities::WORKSPACE_VIEW,
|
||||||
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
|
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
|
||||||
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
|
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD,
|
||||||
],
|
],
|
||||||
|
|
||||||
WorkspaceRole::Operator->value => [
|
WorkspaceRole::Operator->value => [
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Audit\AuditContextSanitizer;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
|
||||||
class AuditLogger
|
class AuditLogger
|
||||||
@ -22,6 +23,10 @@ public function log(
|
|||||||
$metadata = $context['metadata'] ?? [];
|
$metadata = $context['metadata'] ?? [];
|
||||||
unset($context['metadata']);
|
unset($context['metadata']);
|
||||||
|
|
||||||
|
$metadata = is_array($metadata) ? $metadata : [];
|
||||||
|
|
||||||
|
$sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context);
|
||||||
|
|
||||||
return AuditLog::create([
|
return AuditLog::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'actor_id' => $actorId,
|
'actor_id' => $actorId,
|
||||||
@ -31,7 +36,7 @@ public function log(
|
|||||||
'resource_type' => $resourceType,
|
'resource_type' => $resourceType,
|
||||||
'resource_id' => $resourceId,
|
'resource_id' => $resourceId,
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'metadata' => $metadata + $context,
|
'metadata' => $sanitizedMetadata,
|
||||||
'recorded_at' => CarbonImmutable::now(),
|
'recorded_at' => CarbonImmutable::now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,4 +22,9 @@ enum AuditActionId: string
|
|||||||
|
|
||||||
// Diagnostics / repair actions.
|
// Diagnostics / repair actions.
|
||||||
case TenantMembershipDuplicatesMerged = 'tenant_membership.duplicates_merged';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
66
app/Support/Audit/AuditContextSanitizer.php
Normal file
66
app/Support/Audit/AuditContextSanitizer.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -27,6 +27,9 @@ class Capabilities
|
|||||||
|
|
||||||
public const WORKSPACE_MEMBERSHIP_MANAGE = 'workspace_membership.manage';
|
public const WORKSPACE_MEMBERSHIP_MANAGE = 'workspace_membership.manage';
|
||||||
|
|
||||||
|
// Managed tenant onboarding
|
||||||
|
public const WORKSPACE_MANAGED_TENANT_ONBOARD = 'workspace_managed_tenant.onboard';
|
||||||
|
|
||||||
// Tenants
|
// Tenants
|
||||||
public const TENANT_VIEW = 'tenant.view';
|
public const TENANT_VIEW = 'tenant.view';
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ public function spec(mixed $value): BadgeSpec
|
|||||||
$state = BadgeCatalog::normalizeState($value);
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
return match ($state) {
|
return match ($state) {
|
||||||
|
'pending' => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
|
||||||
'active' => new BadgeSpec('Active', 'success', 'heroicon-m-check-circle'),
|
'active' => new BadgeSpec('Active', 'success', 'heroicon-m-check-circle'),
|
||||||
'inactive' => new BadgeSpec('Inactive', 'gray', 'heroicon-m-minus-circle'),
|
'inactive' => new BadgeSpec('Inactive', 'gray', 'heroicon-m-minus-circle'),
|
||||||
'archived' => new BadgeSpec('Archived', 'gray', 'heroicon-m-minus-circle'),
|
'archived' => new BadgeSpec('Archived', 'gray', 'heroicon-m-minus-circle'),
|
||||||
|
|||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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="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="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">
|
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
@if ($this->canRegisterTenant())
|
Switch workspaces, or contact an administrator.
|
||||||
Register a tenant for this workspace, or switch workspaces.
|
|
||||||
@else
|
|
||||||
Switch workspaces, or contact an administrator.
|
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
|
<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
|
<x-filament::button
|
||||||
type="button"
|
type="button"
|
||||||
color="gray"
|
color="gray"
|
||||||
|
|||||||
@ -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>
|
||||||
@ -17,16 +17,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
|
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||||
@if ($this->canRegisterTenant())
|
<x-filament::button
|
||||||
<x-filament::button
|
type="button"
|
||||||
type="button"
|
color="primary"
|
||||||
color="primary"
|
tag="a"
|
||||||
tag="a"
|
href="{{ route('admin.workspace.managed-tenants.onboarding', ['workspace' => $this->workspace->slug ?? $this->workspace->getKey()]) }}"
|
||||||
href="{{ route('filament.admin.tenant.registration') }}"
|
>
|
||||||
>
|
Start onboarding
|
||||||
Add managed tenant
|
</x-filament::button>
|
||||||
</x-filament::button>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<x-filament::button
|
<x-filament::button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Pages\Tenancy\RegisterTenant;
|
|
||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
use App\Http\Controllers\AdminConsentCallbackController;
|
use App\Http\Controllers\AdminConsentCallbackController;
|
||||||
use App\Http\Controllers\Auth\EntraController;
|
use App\Http\Controllers\Auth\EntraController;
|
||||||
@ -29,7 +28,7 @@
|
|||||||
Route::get('/admin/consent/start', TenantOnboardingController::class)
|
Route::get('/admin/consent/start', TenantOnboardingController::class)
|
||||||
->name('admin.consent.start');
|
->name('admin.consent.start');
|
||||||
// Panel root override: keep the app's workspace-first flow.
|
// 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.
|
// when no default tenant can be resolved.
|
||||||
Route::middleware([
|
Route::middleware([
|
||||||
'web',
|
'web',
|
||||||
@ -67,7 +66,7 @@
|
|||||||
$tenantCount = (int) $tenantsQuery->count();
|
$tenantCount = (int) $tenantsQuery->count();
|
||||||
|
|
||||||
if ($tenantCount === 0) {
|
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) {
|
if ($tenantCount === 1) {
|
||||||
@ -81,23 +80,6 @@
|
|||||||
return redirect()->to('/admin/choose-tenant');
|
return redirect()->to('/admin/choose-tenant');
|
||||||
})
|
})
|
||||||
->name('admin.home');
|
->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'])
|
Route::get('/admin/rbac/start', [RbacDelegatedAuthController::class, 'start'])
|
||||||
->name('admin.rbac.start');
|
->name('admin.rbac.start');
|
||||||
@ -112,42 +94,6 @@
|
|||||||
->middleware('throttle:entra-callback')
|
->middleware('throttle:entra-callback')
|
||||||
->name('auth.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'])
|
Route::middleware(['web', 'auth', 'ensure-correct-guard:web'])
|
||||||
->post('/admin/switch-workspace', SwitchWorkspaceController::class)
|
->post('/admin/switch-workspace', SwitchWorkspaceController::class)
|
||||||
->name('admin.switch-workspace');
|
->name('admin.switch-workspace');
|
||||||
@ -173,11 +119,20 @@
|
|||||||
->name('admin.workspace.home');
|
->name('admin.workspace.home');
|
||||||
|
|
||||||
Route::get('/ping', fn () => response()->noContent())->name('admin.workspace.ping');
|
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([
|
Route::middleware([
|
||||||
'web',
|
'web',
|
||||||
'panel:admin',
|
'panel:admin',
|
||||||
|
|||||||
@ -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 repository’s constitution.
|
||||||
|
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||||
@ -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).
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
# Onboarding Wizard — Action Contracts (073)
|
||||||
|
|
||||||
|
These are conceptual contracts for the wizard’s 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.
|
||||||
@ -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) — spec’s `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.
|
||||||
163
specs/073-unified-managed-tenant-onboarding-wizard/plan.md
Normal file
163
specs/073-unified-managed-tenant-onboarding-wizard/plan.md
Normal 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.
|
||||||
@ -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`
|
||||||
@ -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 Filament’s 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 Filament’s 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 don’t 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.
|
||||||
185
specs/073-unified-managed-tenant-onboarding-wizard/spec.md
Normal file
185
specs/073-unified-managed-tenant-onboarding-wizard/spec.md
Normal 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).
|
||||||
159
specs/073-unified-managed-tenant-onboarding-wizard/tasks.md
Normal file
159
specs/073-unified-managed-tenant-onboarding-wizard/tasks.md
Normal 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] T018–T020 can be written in parallel (distinct test cases).
|
||||||
|
|
||||||
|
### US2 parallel work
|
||||||
|
|
||||||
|
- [P] T032–T034 can be written in parallel (selection tests vs run tests vs secret-safety tests).
|
||||||
|
|
||||||
|
### US3 parallel work
|
||||||
|
|
||||||
|
- [P] T040–T042 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.
|
||||||
@ -4,5 +4,5 @@
|
|||||||
|
|
||||||
it('redirects /admin/new to /admin/login for guests', function (): void {
|
it('redirects /admin/new to /admin/login for guests', function (): void {
|
||||||
$this->get('/admin/new')
|
$this->get('/admin/new')
|
||||||
->assertRedirect('/admin/login');
|
->assertNotFound();
|
||||||
});
|
});
|
||||||
|
|||||||
46
tests/Feature/Audit/WorkspaceAuditLoggerRedactionTest.php
Normal file
46
tests/Feature/Audit/WorkspaceAuditLoggerRedactionTest.php
Normal 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');
|
||||||
|
});
|
||||||
@ -3,6 +3,9 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
@ -14,7 +17,18 @@
|
|||||||
$this->get('/admin/login')->assertOk();
|
$this->get('/admin/login')->assertOk();
|
||||||
|
|
||||||
$user = User::factory()->create();
|
$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/no-access')->assertOk();
|
||||||
$this->get('/admin/choose-tenant')->assertOk();
|
$this->get('/admin/choose-tenant')->assertOk();
|
||||||
|
|||||||
@ -4,32 +4,39 @@
|
|||||||
|
|
||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantMembership;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
it('does not allow a non-member user to access tenant-scoped admin routes', function () {
|
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();
|
$nonMember = User::factory()->create();
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
TenantMembership::query()->create([
|
'workspace_id' => $workspace->getKey(),
|
||||||
'tenant_id' => $tenant->getKey(),
|
'user_id' => $nonMember->getKey(),
|
||||||
'user_id' => $member->getKey(),
|
|
||||||
'role' => 'owner',
|
'role' => 'owner',
|
||||||
'source' => 'manual',
|
|
||||||
'source_ref' => null,
|
|
||||||
'created_by_user_id' => null,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actingAs($nonMember);
|
$this->actingAs($nonMember)
|
||||||
$this->get(TenantDashboard::getUrl(tenant: $tenant))->assertNotFound();
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||||
|
->get(TenantDashboard::getUrl(tenant: $tenant))
|
||||||
|
->assertNotFound();
|
||||||
|
|
||||||
$this->actingAs($member);
|
$this->actingAs($member)
|
||||||
$this->get(TenantDashboard::getUrl(tenant: $tenant))->assertSuccessful();
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||||
|
->get(TenantDashboard::getUrl(tenant: $tenant))
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
$this->get('/system')->assertNotFound();
|
$this->get('/system')->assertNotFound();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,7 +10,9 @@
|
|||||||
|
|
||||||
test('backup schedules listing is tenant scoped', function () {
|
test('backup schedules listing is tenant scoped', function () {
|
||||||
[$user, $tenantA] = createUserWithTenant(role: 'manager');
|
[$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');
|
createUserWithTenant(tenant: $tenantB, user: $user, role: 'manager');
|
||||||
|
|
||||||
@ -46,6 +48,10 @@
|
|||||||
|
|
||||||
$this->actingAs($user);
|
$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)))
|
$this->get(route('filament.admin.resources.backup-schedules.index', filamentTenantRouteParams($tenantA)))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Tenant A schedule')
|
->assertSee('Tenant A schedule')
|
||||||
|
|||||||
18
tests/Feature/Badges/TenantStatusBadgeTest.php
Normal file
18
tests/Feature/Badges/TenantStatusBadgeTest.php
Normal 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');
|
||||||
|
});
|
||||||
@ -90,7 +90,9 @@
|
|||||||
|
|
||||||
test('group detail is tenant-scoped and cross-tenant access is forbidden (403)', function () {
|
test('group detail is tenant-scoped and cross-tenant access is forbidden (403)', function () {
|
||||||
$tenantA = Tenant::factory()->create();
|
$tenantA = Tenant::factory()->create();
|
||||||
$tenantB = Tenant::factory()->create();
|
$tenantB = Tenant::factory()->create([
|
||||||
|
'workspace_id' => $tenantA->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
$groupB = EntraGroup::query()->create([
|
$groupB = EntraGroup::query()->create([
|
||||||
'tenant_id' => $tenantB->getKey(),
|
'tenant_id' => $tenantB->getKey(),
|
||||||
@ -103,10 +105,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$user->tenants()->syncWithoutDetaching([
|
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, user: $user, role: 'owner');
|
||||||
$tenantA->getKey() => ['role' => 'owner'],
|
|
||||||
$tenantB->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(EntraGroupResource::getUrl('view', ['record' => $groupB], tenant: $tenantA))
|
->get(EntraGroupResource::getUrl('view', ['record' => $groupB], tenant: $tenantA))
|
||||||
|
|||||||
@ -18,9 +18,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->user = User::factory()->create();
|
$this->user = User::factory()->create();
|
||||||
$this->user->tenants()->syncWithoutDetaching([
|
[$this->user, $this->tenant] = createUserWithTenant(tenant: $this->tenant, user: $this->user, role: 'owner');
|
||||||
$this->tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders policy version view without any Graph calls during render', function () {
|
it('renders policy version view without any Graph calls during render', function () {
|
||||||
|
|||||||
@ -28,7 +28,7 @@
|
|||||||
->actingAs($user)
|
->actingAs($user)
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||||
->get('/admin')
|
->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 {
|
it('redirects /admin to choose-tenant when a workspace is selected and has multiple tenants', function (): void {
|
||||||
|
|||||||
@ -4,34 +4,30 @@
|
|||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
test('policy detail shows app protection settings in readable sections', function () {
|
test('policy detail shows app protection settings in readable sections', function () {
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::factory()->create([
|
||||||
'tenant_id' => 'local-tenant',
|
|
||||||
'name' => 'Tenant One',
|
'name' => 'Tenant One',
|
||||||
'metadata' => [],
|
'status' => 'active',
|
||||||
'is_current' => true,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
$tenant->makeCurrent();
|
|
||||||
|
|
||||||
$policy = Policy::create([
|
$policy = Policy::factory()->create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->getKey(),
|
||||||
'external_id' => 'policy-1',
|
'external_id' => 'policy-1',
|
||||||
'policy_type' => 'appProtectionPolicy',
|
'policy_type' => 'appProtectionPolicy',
|
||||||
'display_name' => 'Teams',
|
'display_name' => 'Teams',
|
||||||
'platform' => 'mobile',
|
'platform' => 'mobile',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
PolicyVersion::create([
|
PolicyVersion::factory()->create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->getKey(),
|
||||||
'policy_id' => $policy->id,
|
'policy_id' => $policy->getKey(),
|
||||||
'version_number' => 1,
|
'version_number' => 1,
|
||||||
'policy_type' => $policy->policy_type,
|
'policy_type' => $policy->policy_type,
|
||||||
'platform' => $policy->platform,
|
'platform' => $policy->platform,
|
||||||
@ -46,11 +42,6 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$response = $this->actingAs($user)
|
||||||
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
||||||
|
|
||||||
|
|||||||
@ -29,7 +29,7 @@
|
|||||||
->assertDontSee('Register tenant');
|
->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();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
@ -44,5 +44,7 @@
|
|||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||||
->get('/admin/choose-tenant')
|
->get('/admin/choose-tenant')
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('Register tenant');
|
->assertSee('No tenants are available')
|
||||||
|
->assertSee('Change workspace')
|
||||||
|
->assertDontSee('Register tenant');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,42 +3,30 @@
|
|||||||
use App\Filament\Resources\PolicyResource;
|
use App\Filament\Resources\PolicyResource;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
$tenant = Tenant::create([
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
'tenant_id' => 'local-tenant',
|
|
||||||
'name' => 'Tenant One',
|
|
||||||
'metadata' => [],
|
|
||||||
'is_current' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
|
||||||
|
|
||||||
$this->tenant = $tenant;
|
$this->tenant = $tenant;
|
||||||
$this->user = User::factory()->create();
|
$this->user = $user;
|
||||||
$this->user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('policy detail renders normalized settings for Autopilot profiles', function () {
|
test('policy detail renders normalized settings for Autopilot profiles', function () {
|
||||||
$policy = Policy::create([
|
$policy = Policy::factory()->create([
|
||||||
'tenant_id' => $this->tenant->id,
|
'tenant_id' => $this->tenant->getKey(),
|
||||||
'external_id' => 'autopilot-1',
|
'external_id' => 'autopilot-1',
|
||||||
'policy_type' => 'windowsAutopilotDeploymentProfile',
|
'policy_type' => 'windowsAutopilotDeploymentProfile',
|
||||||
'display_name' => 'Autopilot Profile A',
|
'display_name' => 'Autopilot Profile A',
|
||||||
'platform' => 'windows',
|
'platform' => 'windows',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
PolicyVersion::create([
|
PolicyVersion::factory()->create([
|
||||||
'tenant_id' => $this->tenant->id,
|
'tenant_id' => $this->tenant->getKey(),
|
||||||
'policy_id' => $policy->id,
|
'policy_id' => $policy->getKey(),
|
||||||
'version_number' => 1,
|
'version_number' => 1,
|
||||||
'policy_type' => $policy->policy_type,
|
'policy_type' => $policy->policy_type,
|
||||||
'platform' => $policy->platform,
|
'platform' => $policy->platform,
|
||||||
@ -71,17 +59,17 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('policy detail renders normalized settings for Enrollment Status Page (ESP)', function () {
|
test('policy detail renders normalized settings for Enrollment Status Page (ESP)', function () {
|
||||||
$policy = Policy::create([
|
$policy = Policy::factory()->create([
|
||||||
'tenant_id' => $this->tenant->id,
|
'tenant_id' => $this->tenant->getKey(),
|
||||||
'external_id' => 'esp-1',
|
'external_id' => 'esp-1',
|
||||||
'policy_type' => 'windowsEnrollmentStatusPage',
|
'policy_type' => 'windowsEnrollmentStatusPage',
|
||||||
'display_name' => 'ESP A',
|
'display_name' => 'ESP A',
|
||||||
'platform' => 'windows',
|
'platform' => 'windows',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
PolicyVersion::create([
|
PolicyVersion::factory()->create([
|
||||||
'tenant_id' => $this->tenant->id,
|
'tenant_id' => $this->tenant->getKey(),
|
||||||
'policy_id' => $policy->id,
|
'policy_id' => $policy->getKey(),
|
||||||
'version_number' => 1,
|
'version_number' => 1,
|
||||||
'policy_type' => $policy->policy_type,
|
'policy_type' => $policy->policy_type,
|
||||||
'platform' => $policy->platform,
|
'platform' => $policy->platform,
|
||||||
@ -113,17 +101,17 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('policy detail renders normalized settings for platform restrictions (enrollment)', function () {
|
test('policy detail renders normalized settings for platform restrictions (enrollment)', function () {
|
||||||
$policy = Policy::create([
|
$policy = Policy::factory()->create([
|
||||||
'tenant_id' => $this->tenant->id,
|
'tenant_id' => $this->tenant->getKey(),
|
||||||
'external_id' => 'enroll-restrict-1',
|
'external_id' => 'enroll-restrict-1',
|
||||||
'policy_type' => 'deviceEnrollmentPlatformRestrictionsConfiguration',
|
'policy_type' => 'deviceEnrollmentPlatformRestrictionsConfiguration',
|
||||||
'display_name' => 'Restriction A',
|
'display_name' => 'Restriction A',
|
||||||
'platform' => 'all',
|
'platform' => 'all',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
PolicyVersion::create([
|
PolicyVersion::factory()->create([
|
||||||
'tenant_id' => $this->tenant->id,
|
'tenant_id' => $this->tenant->getKey(),
|
||||||
'policy_id' => $policy->id,
|
'policy_id' => $policy->getKey(),
|
||||||
'version_number' => 1,
|
'version_number' => 1,
|
||||||
'policy_type' => $policy->policy_type,
|
'policy_type' => $policy->policy_type,
|
||||||
'platform' => $policy->platform,
|
'platform' => $policy->platform,
|
||||||
|
|||||||
@ -22,6 +22,8 @@
|
|||||||
|
|
||||||
test('entra group sync runs are listed for the active tenant', function () {
|
test('entra group sync runs are listed for the active tenant', function () {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
$otherTenant = Tenant::factory()->create();
|
$otherTenant = Tenant::factory()->create();
|
||||||
|
|
||||||
EntraGroupSyncRun::query()->create([
|
EntraGroupSyncRun::query()->create([
|
||||||
@ -38,12 +40,6 @@
|
|||||||
'status' => EntraGroupSyncRun::STATUS_SUCCEEDED,
|
'status' => EntraGroupSyncRun::STATUS_SUCCEEDED,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
$otherTenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(EntraGroupSyncRunResource::getUrl('index', tenant: $tenant))
|
->get(EntraGroupSyncRunResource::getUrl('index', tenant: $tenant))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
@ -53,7 +49,9 @@
|
|||||||
|
|
||||||
test('entra group sync run view is forbidden cross-tenant (403)', function () {
|
test('entra group sync run view is forbidden cross-tenant (403)', function () {
|
||||||
$tenantA = Tenant::factory()->create();
|
$tenantA = Tenant::factory()->create();
|
||||||
$tenantB = Tenant::factory()->create();
|
$tenantB = Tenant::factory()->create([
|
||||||
|
'workspace_id' => $tenantA->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
$runB = EntraGroupSyncRun::query()->create([
|
$runB = EntraGroupSyncRun::query()->create([
|
||||||
'tenant_id' => $tenantB->getKey(),
|
'tenant_id' => $tenantB->getKey(),
|
||||||
@ -63,10 +61,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$user->tenants()->syncWithoutDetaching([
|
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, user: $user, role: 'owner');
|
||||||
$tenantA->getKey() => ['role' => 'owner'],
|
|
||||||
$tenantB->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(EntraGroupSyncRunResource::getUrl('view', ['record' => $runB], tenant: $tenantA))
|
->get(EntraGroupSyncRunResource::getUrl('view', ['record' => $runB], tenant: $tenantA))
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Graph\GraphResponse;
|
use App\Services\Graph\GraphResponse;
|
||||||
use App\Services\Intune\BackupService;
|
use App\Services\Intune\BackupService;
|
||||||
@ -90,11 +89,10 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
$client = new GroupPolicyHydrationGraphClient;
|
$client = new GroupPolicyHydrationGraphClient;
|
||||||
app()->instance(GraphClientInterface::class, $client);
|
app()->instance(GraphClientInterface::class, $client);
|
||||||
|
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::factory()->create([
|
||||||
'tenant_id' => 'tenant-gpo-hydration',
|
'tenant_id' => 'tenant-gpo-hydration',
|
||||||
'name' => 'Tenant One',
|
'name' => 'Tenant One',
|
||||||
'metadata' => [],
|
'status' => 'active',
|
||||||
'is_current' => true,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
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;
|
$_SERVER['INTUNE_TENANT_ID'] = $tenant->tenant_id;
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
$policy = Policy::create([
|
$policy = Policy::factory()->create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->getKey(),
|
||||||
'external_id' => 'gpo-hydrate',
|
'external_id' => 'gpo-hydrate',
|
||||||
'policy_type' => 'groupPolicyConfiguration',
|
'policy_type' => 'groupPolicyConfiguration',
|
||||||
'display_name' => 'Admin Templates Alpha',
|
'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],
|
metadata: ['source' => 'test', 'backup_set_id' => $backupSet->id],
|
||||||
);
|
);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this
|
$response = $this
|
||||||
->actingAs($user)
|
->actingAs($user)
|
||||||
|
|||||||
@ -398,16 +398,14 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('tenant can be archived and hidden from default lists', function () {
|
test('tenant can be archived and hidden from default lists', function () {
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::factory()->create([
|
||||||
'tenant_id' => 'tenant-4',
|
'tenant_id' => 'tenant-4',
|
||||||
'name' => 'Tenant 4',
|
'name' => 'Tenant 4',
|
||||||
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::test(ListTenants::class)
|
Livewire::test(ListTenants::class)
|
||||||
@ -436,64 +434,86 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('tenant table archive filter toggles active and archived tenants', function () {
|
test('tenant table archive filter toggles active and archived tenants', function () {
|
||||||
$active = Tenant::create([
|
$active = Tenant::factory()->create([
|
||||||
'tenant_id' => 'tenant-active',
|
'tenant_id' => 'tenant-active',
|
||||||
'name' => 'Active Tenant',
|
'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',
|
'tenant_id' => 'tenant-archived',
|
||||||
'name' => 'Archived Tenant',
|
'name' => 'Archived Tenant',
|
||||||
|
'status' => 'active',
|
||||||
|
'workspace_id' => $active->workspace_id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$archived->delete();
|
$archived->delete();
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$this->actingAs($user);
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
$user->tenants()->syncWithoutDetaching([
|
||||||
$active->getKey() => ['role' => 'owner'],
|
|
||||||
$archived->getKey() => ['role' => 'owner'],
|
$archived->getKey() => ['role' => 'owner'],
|
||||||
]);
|
]);
|
||||||
Filament::setTenant($active, true);
|
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($active->name)
|
||||||
->assertSee($archived->name);
|
->assertSee($archived->name);
|
||||||
|
|
||||||
$component
|
$component
|
||||||
->set('tableFilters.trashed.value', null)
|
->filterTable('trashed', null)
|
||||||
->assertSee($active->name)
|
->assertSee($active->name)
|
||||||
->assertDontSee($archived->name);
|
->assertDontSee($archived->name);
|
||||||
|
|
||||||
$component
|
$component
|
||||||
->set('tableFilters.trashed.value', 0)
|
->filterTable('trashed', false)
|
||||||
->assertSee($archived->name)
|
->assertSee($archived->name)
|
||||||
->assertDontSee($active->name);
|
->assertDontSee($active->name);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('archived tenant can be restored from the table', function () {
|
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',
|
'tenant_id' => 'tenant-restore',
|
||||||
'name' => 'Restore Tenant',
|
'name' => 'Restore Tenant',
|
||||||
|
'status' => 'active',
|
||||||
|
'workspace_id' => $contextTenant->workspace_id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tenant->delete();
|
$tenant->delete();
|
||||||
|
|
||||||
$contextTenant = Tenant::create([
|
|
||||||
'tenant_id' => 'tenant-restore-context',
|
|
||||||
'name' => 'Restore Context Tenant',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$this->actingAs($user);
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
$user->tenants()->syncWithoutDetaching([
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
$tenant->getKey() => ['role' => 'owner'],
|
||||||
$contextTenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
]);
|
||||||
Filament::setTenant($contextTenant, true);
|
Filament::setTenant($contextTenant, true);
|
||||||
|
|
||||||
Livewire::test(ListTenants::class)
|
$this->withSession([
|
||||||
->set('tableFilters.trashed.value', 1)
|
\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);
|
->callTableAction('restore', $tenant);
|
||||||
|
|
||||||
$this->assertDatabaseHas('tenants', [
|
$this->assertDatabaseHas('tenants', [
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
|
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
|
||||||
use App\Models\InventoryItem;
|
use App\Models\InventoryItem;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use App\Support\Auth\UiTooltips;
|
use App\Support\Auth\UiTooltips;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
@ -18,6 +17,8 @@
|
|||||||
|
|
||||||
test('inventory items are listed for the active tenant', function () {
|
test('inventory items are listed for the active tenant', function () {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
$otherTenant = Tenant::factory()->create();
|
$otherTenant = Tenant::factory()->create();
|
||||||
|
|
||||||
InventoryItem::factory()->create([
|
InventoryItem::factory()->create([
|
||||||
@ -36,12 +37,6 @@
|
|||||||
'platform' => 'windows',
|
'platform' => 'windows',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
$otherTenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(InventoryItemResource::getUrl('index', tenant: $tenant))
|
->get(InventoryItemResource::getUrl('index', tenant: $tenant))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
|
|||||||
@ -6,17 +6,12 @@
|
|||||||
use App\Models\InventoryItem;
|
use App\Models\InventoryItem;
|
||||||
use App\Models\InventorySyncRun;
|
use App\Models\InventorySyncRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
|
|
||||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
test('inventory hub pages load for a tenant', function () {
|
test('inventory hub pages load for a tenant', function () {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
$user = User::factory()->create();
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
InventoryItem::factory()->create([
|
InventoryItem::factory()->create([
|
||||||
'tenant_id' => $tenant->getKey(),
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
use App\Filament\Resources\InventorySyncRunResource;
|
use App\Filament\Resources\InventorySyncRunResource;
|
||||||
use App\Models\InventorySyncRun;
|
use App\Models\InventorySyncRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
@ -14,6 +13,8 @@
|
|||||||
|
|
||||||
test('inventory sync runs are listed for the active tenant', function () {
|
test('inventory sync runs are listed for the active tenant', function () {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
$otherTenant = Tenant::factory()->create();
|
$otherTenant = Tenant::factory()->create();
|
||||||
|
|
||||||
InventorySyncRun::factory()->create([
|
InventorySyncRun::factory()->create([
|
||||||
@ -28,12 +29,6 @@
|
|||||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
$otherTenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(InventorySyncRunResource::getUrl('index', tenant: $tenant))
|
->get(InventorySyncRunResource::getUrl('index', tenant: $tenant))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
|
|||||||
@ -5,20 +5,19 @@
|
|||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
test('malformed snapshot renders warning on policy and version detail', function () {
|
test('malformed snapshot renders warning on policy and version detail', function () {
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::factory()->create([
|
||||||
'tenant_id' => 'local-tenant',
|
'tenant_id' => 'local-tenant',
|
||||||
'name' => 'Tenant One',
|
'name' => 'Tenant One',
|
||||||
'metadata' => [],
|
'status' => 'active',
|
||||||
'is_current' => true,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
$policy = Policy::create([
|
$policy = Policy::create([
|
||||||
@ -40,11 +39,6 @@
|
|||||||
'snapshot' => ['a', 'b'], // list-based snapshot should trigger warning
|
'snapshot' => ['a', 'b'], // list-based snapshot should trigger warning
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$policyResponse = $this->actingAs($user)
|
$policyResponse = $this->actingAs($user)
|
||||||
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Graph\GraphResponse;
|
use App\Services\Graph\GraphResponse;
|
||||||
use App\Services\Intune\RestoreService;
|
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',
|
'tenant_id' => 'local-tenant',
|
||||||
'name' => 'Tenant One',
|
'name' => 'Tenant One',
|
||||||
'metadata' => [],
|
'status' => 'active',
|
||||||
'is_current' => true,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
$policy = Policy::create([
|
$policy = Policy::create([
|
||||||
@ -99,11 +98,6 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
|||||||
'payload' => $snapshot,
|
'payload' => $snapshot,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$detailResponse = $this->actingAs($user)
|
$detailResponse = $this->actingAs($user)
|
||||||
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
||||||
|
|
||||||
|
|||||||
@ -30,8 +30,8 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
|
||||||
$user->tenants()->syncWithoutDetaching([
|
$user->tenants()->syncWithoutDetaching([
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
$otherTenant->getKey() => ['role' => 'owner'],
|
$otherTenant->getKey() => ['role' => 'owner'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -11,14 +11,7 @@
|
|||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
test('policy detail shows normalized settings section', function () {
|
test('policy detail shows normalized settings section', function () {
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::factory()->create();
|
||||||
'tenant_id' => 'local-tenant',
|
|
||||||
'name' => 'Tenant One',
|
|
||||||
'metadata' => [],
|
|
||||||
'is_current' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
|
||||||
|
|
||||||
$policy = Policy::create([
|
$policy = Policy::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
@ -49,9 +42,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$user->tenants()->syncWithoutDetaching([
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$response = $this->actingAs($user)
|
||||||
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
||||||
|
|||||||
@ -11,14 +11,7 @@
|
|||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
test('policy settings standard view renders array values without crashing', function () {
|
test('policy settings standard view renders array values without crashing', function () {
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::factory()->create();
|
||||||
'tenant_id' => 'tenant-arrays',
|
|
||||||
'name' => 'Tenant Arrays',
|
|
||||||
'metadata' => [],
|
|
||||||
'is_current' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
|
||||||
|
|
||||||
$policy = Policy::create([
|
$policy = Policy::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
@ -48,9 +41,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$user->tenants()->syncWithoutDetaching([
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$response = $this->actingAs($user)
|
||||||
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant).'?tab=settings');
|
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant).'?tab=settings');
|
||||||
|
|||||||
@ -11,14 +11,7 @@
|
|||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
test('policy version detail renders tabs and scroll-safe blocks', function () {
|
test('policy version detail renders tabs and scroll-safe blocks', function () {
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::factory()->create();
|
||||||
'tenant_id' => 'local-tenant',
|
|
||||||
'name' => 'Tenant One',
|
|
||||||
'metadata' => [],
|
|
||||||
'is_current' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
|
||||||
|
|
||||||
$policy = Policy::create([
|
$policy = Policy::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
@ -58,9 +51,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$user->tenants()->syncWithoutDetaching([
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$response = $this->actingAs($user)
|
||||||
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
|
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
|
||||||
|
|||||||
@ -4,33 +4,30 @@
|
|||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
test('policy version view shows scope tags even when assignments are missing', function () {
|
test('policy version view shows scope tags even when assignments are missing', function () {
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::factory()->create([
|
||||||
'tenant_id' => 'local-tenant',
|
|
||||||
'name' => 'Tenant One',
|
'name' => 'Tenant One',
|
||||||
'metadata' => [],
|
'status' => 'active',
|
||||||
'is_current' => true,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
$policy = Policy::create([
|
$policy = Policy::factory()->create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->getKey(),
|
||||||
'external_id' => 'policy-1',
|
'external_id' => 'policy-1',
|
||||||
'policy_type' => 'deviceConfiguration',
|
'policy_type' => 'deviceConfiguration',
|
||||||
'display_name' => 'Policy A',
|
'display_name' => 'Policy A',
|
||||||
'platform' => 'windows',
|
'platform' => 'windows',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$version = PolicyVersion::create([
|
$version = PolicyVersion::factory()->create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->getKey(),
|
||||||
'policy_id' => $policy->id,
|
'policy_id' => $policy->getKey(),
|
||||||
'version_number' => 1,
|
'version_number' => 1,
|
||||||
'policy_type' => $policy->policy_type,
|
'policy_type' => $policy->policy_type,
|
||||||
'platform' => $policy->platform,
|
'platform' => $policy->platform,
|
||||||
@ -46,11 +43,6 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$response = $this->actingAs($user)
|
||||||
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
|
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
|
||||||
|
|
||||||
|
|||||||
@ -4,33 +4,30 @@
|
|||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
test('policy version detail shows raw and normalized settings', function () {
|
test('policy version detail shows raw and normalized settings', function () {
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::factory()->create([
|
||||||
'tenant_id' => 'local-tenant',
|
|
||||||
'name' => 'Tenant One',
|
'name' => 'Tenant One',
|
||||||
'metadata' => [],
|
'status' => 'active',
|
||||||
'is_current' => true,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
$policy = Policy::create([
|
$policy = Policy::factory()->create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->getKey(),
|
||||||
'external_id' => 'policy-1',
|
'external_id' => 'policy-1',
|
||||||
'policy_type' => 'deviceConfiguration',
|
'policy_type' => 'deviceConfiguration',
|
||||||
'display_name' => 'Policy A',
|
'display_name' => 'Policy A',
|
||||||
'platform' => 'windows',
|
'platform' => 'windows',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$version = PolicyVersion::create([
|
$version = PolicyVersion::factory()->create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->getKey(),
|
||||||
'policy_id' => $policy->id,
|
'policy_id' => $policy->getKey(),
|
||||||
'version_number' => 1,
|
'version_number' => 1,
|
||||||
'policy_type' => $policy->policy_type,
|
'policy_type' => $policy->policy_type,
|
||||||
'platform' => $policy->platform,
|
'platform' => $policy->platform,
|
||||||
@ -44,11 +41,6 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$response = $this->actingAs($user)
|
||||||
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
|
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
|
||||||
|
|
||||||
@ -61,26 +53,25 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('policy version detail shows enrollment notification template settings', function () {
|
test('policy version detail shows enrollment notification template settings', function () {
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::factory()->create([
|
||||||
'tenant_id' => 'tenant-enrollment-notify',
|
'tenant_id' => 'tenant-enrollment-notify',
|
||||||
'name' => 'Tenant Enrollment Notify',
|
'name' => 'Tenant Enrollment Notify',
|
||||||
'metadata' => [],
|
'status' => 'active',
|
||||||
'is_current' => true,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
$policy = Policy::create([
|
$policy = Policy::factory()->create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->getKey(),
|
||||||
'external_id' => 'enroll-notify-1',
|
'external_id' => 'enroll-notify-1',
|
||||||
'policy_type' => 'deviceEnrollmentNotificationConfiguration',
|
'policy_type' => 'deviceEnrollmentNotificationConfiguration',
|
||||||
'display_name' => 'Enrollment Notifications',
|
'display_name' => 'Enrollment Notifications',
|
||||||
'platform' => 'all',
|
'platform' => 'all',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$version = PolicyVersion::create([
|
$version = PolicyVersion::factory()->create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->getKey(),
|
||||||
'policy_id' => $policy->id,
|
'policy_id' => $policy->getKey(),
|
||||||
'version_number' => 1,
|
'version_number' => 1,
|
||||||
'policy_type' => $policy->policy_type,
|
'policy_type' => $policy->policy_type,
|
||||||
'platform' => $policy->platform,
|
'platform' => $policy->platform,
|
||||||
@ -134,11 +125,6 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$response = $this->actingAs($user)
|
||||||
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant).'?tab=normalized-settings');
|
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant).'?tab=normalized-settings');
|
||||||
|
|
||||||
|
|||||||
@ -10,13 +10,7 @@
|
|||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
test('policy versions render with timeline data', function () {
|
test('policy versions render with timeline data', function () {
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::factory()->create();
|
||||||
'tenant_id' => 'local-tenant',
|
|
||||||
'name' => 'Tenant One',
|
|
||||||
'metadata' => [],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
|
||||||
|
|
||||||
$policy = Policy::create([
|
$policy = Policy::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
@ -31,9 +25,7 @@
|
|||||||
$service->captureVersion($policy, ['value' => 2], 'tester');
|
$service->captureVersion($policy, ['value' => 2], 'tester');
|
||||||
|
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$user->tenants()->syncWithoutDetaching([
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(route('filament.admin.resources.policy-versions.index', filamentTenantRouteParams($tenant)))
|
->get(route('filament.admin.resources.policy-versions.index', filamentTenantRouteParams($tenant)))
|
||||||
|
|||||||
@ -5,20 +5,19 @@
|
|||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\SettingsCatalogDefinition;
|
use App\Models\SettingsCatalogDefinition;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
it('shows Settings tab for Settings Catalog policy', function () {
|
it('shows Settings tab for Settings Catalog policy', function () {
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::factory()->create([
|
||||||
'tenant_id' => 'local-tenant',
|
'tenant_id' => 'local-tenant',
|
||||||
'name' => 'Test Tenant',
|
'name' => 'Test Tenant',
|
||||||
'metadata' => [],
|
'status' => 'active',
|
||||||
'is_current' => true,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
$policy = Policy::create([
|
$policy = Policy::create([
|
||||||
@ -70,11 +69,6 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$response = $this->actingAs($user)
|
||||||
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
||||||
|
|
||||||
@ -87,13 +81,13 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows display names instead of definition IDs', function () {
|
it('shows display names instead of definition IDs', function () {
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::factory()->create([
|
||||||
'tenant_id' => 'local-tenant',
|
'tenant_id' => 'local-tenant',
|
||||||
'name' => 'Test Tenant',
|
'name' => 'Test Tenant',
|
||||||
'metadata' => [],
|
'status' => 'active',
|
||||||
'is_current' => true,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
$policy = Policy::create([
|
$policy = Policy::create([
|
||||||
@ -132,11 +126,6 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$response = $this->actingAs($user)
|
||||||
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
||||||
|
|
||||||
@ -146,13 +135,13 @@
|
|||||||
})->skip('Manual UI verification required');
|
})->skip('Manual UI verification required');
|
||||||
|
|
||||||
it('shows fallback prettified labels when definitions not cached', function () {
|
it('shows fallback prettified labels when definitions not cached', function () {
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::factory()->create([
|
||||||
'tenant_id' => 'local-tenant',
|
'tenant_id' => 'local-tenant',
|
||||||
'name' => 'Test Tenant',
|
'name' => 'Test Tenant',
|
||||||
'metadata' => [],
|
'status' => 'active',
|
||||||
'is_current' => true,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
$policy = Policy::create([
|
$policy = Policy::create([
|
||||||
@ -186,11 +175,6 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$response = $this->actingAs($user)
|
||||||
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
||||||
|
|
||||||
@ -200,13 +184,13 @@
|
|||||||
})->skip('Manual UI verification required');
|
})->skip('Manual UI verification required');
|
||||||
|
|
||||||
it('shows tabbed layout for non-Settings Catalog policies', function () {
|
it('shows tabbed layout for non-Settings Catalog policies', function () {
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::factory()->create([
|
||||||
'tenant_id' => 'local-tenant',
|
'tenant_id' => 'local-tenant',
|
||||||
'name' => 'Test Tenant',
|
'name' => 'Test Tenant',
|
||||||
'metadata' => [],
|
'status' => 'active',
|
||||||
'is_current' => true,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
$policy = Policy::create([
|
$policy = Policy::create([
|
||||||
@ -233,11 +217,6 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$response = $this->actingAs($user)
|
||||||
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
||||||
|
|
||||||
@ -249,11 +228,13 @@
|
|||||||
|
|
||||||
// T034: Test display names shown (not definition IDs)
|
// T034: Test display names shown (not definition IDs)
|
||||||
it('displays setting display names instead of raw definition IDs', function () {
|
it('displays setting display names instead of raw definition IDs', function () {
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::factory()->create([
|
||||||
'tenant_id' => 'local-tenant',
|
'tenant_id' => 'local-tenant',
|
||||||
'name' => 'Test Tenant',
|
'name' => 'Test Tenant',
|
||||||
'is_current' => true,
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
SettingsCatalogDefinition::create([
|
SettingsCatalogDefinition::create([
|
||||||
@ -292,10 +273,6 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
$response = $this->actingAs($user)
|
$response = $this->actingAs($user)
|
||||||
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
||||||
|
|
||||||
@ -306,11 +283,13 @@
|
|||||||
|
|
||||||
// T035: Test values formatted correctly
|
// T035: Test values formatted correctly
|
||||||
it('formats setting values correctly based on type', function () {
|
it('formats setting values correctly based on type', function () {
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::factory()->create([
|
||||||
'tenant_id' => 'local-tenant',
|
'tenant_id' => 'local-tenant',
|
||||||
'name' => 'Test Tenant',
|
'name' => 'Test Tenant',
|
||||||
'is_current' => true,
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
SettingsCatalogDefinition::create([
|
SettingsCatalogDefinition::create([
|
||||||
@ -370,10 +349,6 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
$response = $this->actingAs($user)
|
$response = $this->actingAs($user)
|
||||||
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
||||||
|
|
||||||
@ -383,11 +358,13 @@
|
|||||||
|
|
||||||
// T036: Test search/filter functionality
|
// T036: Test search/filter functionality
|
||||||
it('search filters settings in real-time', function () {
|
it('search filters settings in real-time', function () {
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::factory()->create([
|
||||||
'tenant_id' => 'local-tenant',
|
'tenant_id' => 'local-tenant',
|
||||||
'name' => 'Test Tenant',
|
'name' => 'Test Tenant',
|
||||||
'is_current' => true,
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
SettingsCatalogDefinition::create([
|
SettingsCatalogDefinition::create([
|
||||||
@ -436,10 +413,6 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
$response = $this->actingAs($user)
|
$response = $this->actingAs($user)
|
||||||
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
||||||
|
|
||||||
@ -449,11 +422,13 @@
|
|||||||
|
|
||||||
// T037: Test graceful degradation for missing definitions
|
// T037: Test graceful degradation for missing definitions
|
||||||
it('shows prettified fallback labels when definitions are not cached', function () {
|
it('shows prettified fallback labels when definitions are not cached', function () {
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::factory()->create([
|
||||||
'tenant_id' => 'local-tenant',
|
'tenant_id' => 'local-tenant',
|
||||||
'name' => 'Test Tenant',
|
'name' => 'Test Tenant',
|
||||||
'is_current' => true,
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
$policy = Policy::create([
|
$policy = Policy::create([
|
||||||
@ -485,10 +460,6 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
$response = $this->actingAs($user)
|
$response = $this->actingAs($user)
|
||||||
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
@ -13,10 +12,7 @@
|
|||||||
putenv('INTUNE_TENANT_ID=');
|
putenv('INTUNE_TENANT_ID=');
|
||||||
|
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
@ -80,10 +76,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
@ -148,10 +141,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Graph\GraphResponse;
|
use App\Services\Graph\GraphResponse;
|
||||||
use App\Services\Intune\BackupService;
|
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],
|
metadata: ['source' => 'test', 'backup_set_id' => $backupSet->id],
|
||||||
);
|
);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this
|
$response = $this
|
||||||
->actingAs($user)
|
->actingAs($user)
|
||||||
@ -147,10 +143,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
$versions = app(VersionService::class);
|
$versions = app(VersionService::class);
|
||||||
$versions->captureFromGraph($tenant, $policy, createdBy: 'tester@example.com');
|
$versions->captureFromGraph($tenant, $policy, createdBy: 'tester@example.com');
|
||||||
|
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this
|
$response = $this
|
||||||
->actingAs($user)
|
->actingAs($user)
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
@ -89,10 +88,7 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$policyResponse = $this->actingAs($user)
|
$policyResponse = $this->actingAs($user)
|
||||||
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Graph\GraphResponse;
|
use App\Services\Graph\GraphResponse;
|
||||||
use App\Services\Intune\PolicySyncService;
|
use App\Services\Intune\PolicySyncService;
|
||||||
@ -110,10 +109,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this
|
$response = $this
|
||||||
->actingAs($user)
|
->actingAs($user)
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Graph\GraphResponse;
|
use App\Services\Graph\GraphResponse;
|
||||||
use App\Services\Intune\RestoreService;
|
use App\Services\Intune\RestoreService;
|
||||||
@ -146,10 +145,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
'payload' => $payload,
|
'payload' => $payload,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
$service = app(RestoreService::class);
|
$service = app(RestoreService::class);
|
||||||
|
|||||||
@ -161,10 +161,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
'payload' => $payload,
|
'payload' => $payload,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
$service = app(RestoreService::class);
|
$service = app(RestoreService::class);
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
@ -55,10 +54,7 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$policyResponse = $this->actingAs($user)
|
$policyResponse = $this->actingAs($user)
|
||||||
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant).'?tab=settings');
|
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant).'?tab=settings');
|
||||||
|
|||||||
@ -32,7 +32,7 @@
|
|||||||
assertNoOutboundHttp(function () use ($tenant): void {
|
assertNoOutboundHttp(function () use ($tenant): void {
|
||||||
$this->get(TenantDashboard::getUrl(tenant: $tenant))
|
$this->get(TenantDashboard::getUrl(tenant: $tenant))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee("/admin/t/{$tenant->external_id}/workspaces", false)
|
->assertSee('/admin/workspaces', false)
|
||||||
->assertSee('Needs Attention')
|
->assertSee('Needs Attention')
|
||||||
->assertSee('Recent Operations')
|
->assertSee('Recent Operations')
|
||||||
->assertSee('Recent Drift Findings');
|
->assertSee('Recent Drift Findings');
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
||||||
use App\Jobs\BulkTenantSyncJob;
|
use App\Jobs\BulkTenantSyncJob;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use App\Support\Auth\UiTooltips;
|
use App\Support\Auth\UiTooltips;
|
||||||
use Filament\Events\TenantSet;
|
use Filament\Events\TenantSet;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -28,15 +27,11 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('tenant portfolio tenant view returns 404 for non-member tenant record', function () {
|
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']);
|
$authorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-authorized-view']);
|
||||||
$unauthorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-unauthorized-view']);
|
$unauthorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-unauthorized-view']);
|
||||||
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
[$user, $authorizedTenant] = createUserWithTenant($authorizedTenant, role: 'owner');
|
||||||
$authorizedTenant->getKey() => ['role' => 'owner'],
|
$this->actingAs($user);
|
||||||
]);
|
|
||||||
|
|
||||||
$this->get(route('filament.admin.resources.tenants.view', array_merge(
|
$this->get(route('filament.admin.resources.tenants.view', array_merge(
|
||||||
filamentTenantRouteParams($unauthorizedTenant),
|
filamentTenantRouteParams($unauthorizedTenant),
|
||||||
@ -45,15 +40,11 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('tenant portfolio tenant edit returns 404 for non-member tenant record', function () {
|
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']);
|
$authorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-authorized-edit']);
|
||||||
$unauthorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-unauthorized-edit']);
|
$unauthorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-unauthorized-edit']);
|
||||||
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
[$user, $authorizedTenant] = createUserWithTenant($authorizedTenant, role: 'owner');
|
||||||
$authorizedTenant->getKey() => ['role' => 'owner'],
|
$this->actingAs($user);
|
||||||
]);
|
|
||||||
|
|
||||||
$this->get(route('filament.admin.resources.tenants.edit', array_merge(
|
$this->get(route('filament.admin.resources.tenants.edit', array_merge(
|
||||||
filamentTenantRouteParams($unauthorizedTenant),
|
filamentTenantRouteParams($unauthorizedTenant),
|
||||||
@ -62,9 +53,6 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('tenant portfolio lists only tenants the user can access', function () {
|
test('tenant portfolio lists only tenants the user can access', function () {
|
||||||
$user = User::factory()->create();
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
$authorizedTenant = Tenant::factory()->create([
|
$authorizedTenant = Tenant::factory()->create([
|
||||||
'tenant_id' => 'tenant-portfolio-authorized',
|
'tenant_id' => 'tenant-portfolio-authorized',
|
||||||
'name' => 'Authorized Tenant',
|
'name' => 'Authorized Tenant',
|
||||||
@ -75,9 +63,8 @@
|
|||||||
'name' => 'Unauthorized Tenant',
|
'name' => 'Unauthorized Tenant',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
[$user, $authorizedTenant] = createUserWithTenant($authorizedTenant, role: 'owner');
|
||||||
$authorizedTenant->getKey() => ['role' => 'owner'],
|
$this->actingAs($user);
|
||||||
]);
|
|
||||||
|
|
||||||
$this->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($authorizedTenant)))
|
$this->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($authorizedTenant)))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
@ -88,14 +75,16 @@
|
|||||||
test('tenant portfolio bulk sync dispatches one job per eligible tenant', function () {
|
test('tenant portfolio bulk sync dispatches one job per eligible tenant', function () {
|
||||||
Bus::fake();
|
Bus::fake();
|
||||||
|
|
||||||
$user = User::factory()->create();
|
$tenantA = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-a']);
|
||||||
|
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
$tenantA = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-a']);
|
$tenantB = Tenant::factory()->create([
|
||||||
$tenantB = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-b']);
|
'tenant_id' => 'tenant-bulk-b',
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
$user->tenants()->syncWithoutDetaching([
|
||||||
$tenantA->getKey() => ['role' => 'owner'],
|
|
||||||
$tenantB->getKey() => ['role' => 'operator'],
|
$tenantB->getKey() => ['role' => 'operator'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -118,14 +107,9 @@
|
|||||||
test('tenant portfolio bulk sync is disabled for readonly users', function () {
|
test('tenant portfolio bulk sync is disabled for readonly users', function () {
|
||||||
Bus::fake();
|
Bus::fake();
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
$tenant = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-readonly']);
|
$tenant = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-readonly']);
|
||||||
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly');
|
||||||
$user->tenants()->syncWithoutDetaching([
|
$this->actingAs($user);
|
||||||
$tenant->getKey() => ['role' => 'readonly'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
@ -146,14 +130,16 @@
|
|||||||
test('tenant portfolio bulk sync is disabled when selection includes unauthorized tenants', function () {
|
test('tenant portfolio bulk sync is disabled when selection includes unauthorized tenants', function () {
|
||||||
Bus::fake();
|
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);
|
$this->actingAs($user);
|
||||||
|
|
||||||
$tenantA = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-mixed-a']);
|
$tenantB = Tenant::factory()->create([
|
||||||
$tenantB = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-mixed-b']);
|
'tenant_id' => 'tenant-bulk-mixed-b',
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
$user->tenants()->syncWithoutDetaching([
|
||||||
$tenantA->getKey() => ['role' => 'owner'],
|
|
||||||
$tenantB->getKey() => ['role' => 'readonly'],
|
$tenantB->getKey() => ['role' => 'readonly'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||||
use App\Http\Controllers\RbacDelegatedAuthController;
|
use App\Http\Controllers\RbacDelegatedAuthController;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Graph\GraphResponse;
|
use App\Services\Graph\GraphResponse;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -31,11 +30,8 @@ function tenantWithApp(): Tenant
|
|||||||
|
|
||||||
test('rbac action prompts login when no delegated token', function () {
|
test('rbac action prompts login when no delegated token', function () {
|
||||||
$tenant = tenantWithApp();
|
$tenant = tenantWithApp();
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
@ -54,11 +50,8 @@ function tenantWithApp(): Tenant
|
|||||||
|
|
||||||
test('rbac action succeeds and clears token cache', function () {
|
test('rbac action succeeds and clears token cache', function () {
|
||||||
$tenant = tenantWithApp();
|
$tenant = tenantWithApp();
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
$cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
|
$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 () {
|
test('rbac action is idempotent on rerun', function () {
|
||||||
$tenant = tenantWithApp();
|
$tenant = tenantWithApp();
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
$cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
|
$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 () {
|
test('existing group membership error from Graph json payload is treated idempotently', function () {
|
||||||
$tenant = tenantWithApp();
|
$tenant = tenantWithApp();
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
$cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
|
$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 () {
|
test('group picker is disabled without delegated token', function () {
|
||||||
$tenant = tenantWithApp();
|
$tenant = tenantWithApp();
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
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 () {
|
test('group picker toggles when switching modes', function () {
|
||||||
$tenant = tenantWithApp();
|
$tenant = tenantWithApp();
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
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 () {
|
test('delegated group search returns options and persists selection', function () {
|
||||||
$tenant = tenantWithApp();
|
$tenant = tenantWithApp();
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
$cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
|
$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 () {
|
test('delegated role search returns options and persists role definition id', function () {
|
||||||
$tenant = tenantWithApp();
|
$tenant = tenantWithApp();
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
$cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
|
$cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
|
||||||
|
|||||||
@ -59,9 +59,8 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
'tenant_id' => 'tenant-context',
|
'tenant_id' => 'tenant-context',
|
||||||
'name' => 'Context Tenant',
|
'name' => 'Context Tenant',
|
||||||
]);
|
]);
|
||||||
$user->tenants()->syncWithoutDetaching([
|
[$user, $contextTenant] = createUserWithTenant($contextTenant, $user, role: 'owner');
|
||||||
$contextTenant->getKey() => ['role' => 'owner'],
|
$this->actingAs($user);
|
||||||
]);
|
|
||||||
Filament::setTenant($contextTenant, true);
|
Filament::setTenant($contextTenant, true);
|
||||||
|
|
||||||
Livewire::test(CreateTenant::class)
|
Livewire::test(CreateTenant::class)
|
||||||
@ -134,16 +133,15 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
});
|
});
|
||||||
|
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::factory()->create([
|
||||||
'tenant_id' => 'tenant-error',
|
'tenant_id' => 'tenant-error',
|
||||||
'name' => 'Error Tenant',
|
'name' => 'Error Tenant',
|
||||||
]);
|
'status' => 'active',
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
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',
|
'tenant_id' => 'tenant-ui',
|
||||||
'name' => 'UI Tenant',
|
'name' => 'UI Tenant',
|
||||||
]);
|
]);
|
||||||
$user->tenants()->syncWithoutDetaching([
|
[$user, $tenant] = createUserWithTenant($tenant, $user, role: 'owner');
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
$this->actingAs($user);
|
||||||
]);
|
|
||||||
|
|
||||||
config(['intune_permissions.granted_stub' => []]);
|
config(['intune_permissions.granted_stub' => []]);
|
||||||
|
|
||||||
@ -207,9 +204,8 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
'app_client_id' => 'client-123',
|
'app_client_id' => 'client-123',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
[$user, $tenant] = createUserWithTenant($tenant, $user, role: 'owner');
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
$this->actingAs($user);
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant)));
|
$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',
|
'tenant_id' => 'tenant-ui-deactivate',
|
||||||
'name' => 'UI Tenant 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);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
@ -47,10 +46,7 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$response = $this->actingAs($user)
|
||||||
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
@ -46,16 +45,11 @@
|
|||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspaceA->getKey());
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspaceA->getKey());
|
||||||
$user->forceFill(['last_workspace_id' => (int) $workspaceA->getKey()])->save();
|
$user->forceFill(['last_workspace_id' => (int) $workspaceA->getKey()])->save();
|
||||||
|
|
||||||
Filament::setTenant(null, true);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(route('filament.admin.pages.choose-tenant'))
|
->get(route('filament.admin.pages.choose-tenant'))
|
||||||
->assertOk();
|
->assertOk()
|
||||||
|
->assertSee($tenantA->name)
|
||||||
expect(Filament::getTenant())
|
->assertDontSee($tenantB->name);
|
||||||
->toBeInstanceOf(Tenant::class)
|
|
||||||
->and(Filament::getTenant()?->getKey())
|
|
||||||
->toBe($tenantA->getKey());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('user menu renders a workspace switcher when a workspace is selected', function () {
|
test('user menu renders a workspace switcher when a workspace is selected', function () {
|
||||||
|
|||||||
874
tests/Feature/ManagedTenantOnboardingWizardTest.php
Normal file
874
tests/Feature/ManagedTenantOnboardingWizardTest.php
Normal 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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use App\Models\Tenant;
|
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();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user] = createUserWithTenant($tenant, role: 'owner');
|
[$user] = createUserWithTenant($tenant, role: 'owner');
|
||||||
|
|
||||||
@ -13,5 +13,5 @@
|
|||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get('/admin/new')
|
->get('/admin/new')
|
||||||
->assertRedirect('/admin/w/'.$workspace->slug.'/managed-tenants/onboarding');
|
->assertNotFound();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,8 +8,7 @@
|
|||||||
|
|
||||||
it('allows access to monitoring page for tenant members', function () {
|
it('allows access to monitoring page for tenant members', function () {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
$tenant->users()->attach($user, ['role' => 'owner']);
|
|
||||||
|
|
||||||
$run = OperationRun::create([
|
$run = OperationRun::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
@ -28,8 +27,7 @@
|
|||||||
|
|
||||||
it('renders monitoring pages DB-only (never calls Graph)', function () {
|
it('renders monitoring pages DB-only (never calls Graph)', function () {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
$tenant->users()->attach($user, ['role' => 'owner']);
|
|
||||||
|
|
||||||
$run = OperationRun::create([
|
$run = OperationRun::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
@ -61,8 +59,7 @@
|
|||||||
it('shows runs only for current tenant', function () {
|
it('shows runs only for current tenant', function () {
|
||||||
$tenantA = Tenant::factory()->create();
|
$tenantA = Tenant::factory()->create();
|
||||||
$tenantB = Tenant::factory()->create();
|
$tenantB = Tenant::factory()->create();
|
||||||
$user = User::factory()->create();
|
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
|
||||||
$tenantA->users()->attach($user, ['role' => 'owner']);
|
|
||||||
|
|
||||||
// We must simulate being in tenant context
|
// We must simulate being in tenant context
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
@ -99,8 +96,7 @@
|
|||||||
|
|
||||||
it('allows readonly users to view operations list and detail', function () {
|
it('allows readonly users to view operations list and detail', function () {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly');
|
||||||
$tenant->users()->attach($user, ['role' => 'readonly']);
|
|
||||||
|
|
||||||
$run = OperationRun::create([
|
$run = OperationRun::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
|
|||||||
@ -2,14 +2,12 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
use App\Filament\Resources\OperationRunResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use Illuminate\Notifications\DatabaseNotification;
|
use Illuminate\Notifications\DatabaseNotification;
|
||||||
|
|
||||||
it('sanitizes persisted run failures and terminal notifications', function () {
|
it('sanitizes persisted run failures and terminal notifications', function () {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
$tenant->users()->attach($user, ['role' => 'owner']);
|
|
||||||
|
|
||||||
/** @var OperationRunService $runs */
|
/** @var OperationRunService $runs */
|
||||||
$runs = app(OperationRunService::class);
|
$runs = app(OperationRunService::class);
|
||||||
|
|||||||
@ -14,9 +14,7 @@
|
|||||||
'tenant_id' => $this->tenant->id,
|
'tenant_id' => $this->tenant->id,
|
||||||
]);
|
]);
|
||||||
$this->user = User::factory()->create();
|
$this->user = User::factory()->create();
|
||||||
$this->user->tenants()->syncWithoutDetaching([
|
[$this->user, $this->tenant] = createUserWithTenant($this->tenant, $this->user, role: 'owner');
|
||||||
$this->tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays policy version page', function () {
|
it('displays policy version page', function () {
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
use App\Filament\Resources\OperationRunResource;
|
use App\Filament\Resources\OperationRunResource;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
|
|
||||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
@ -25,9 +24,10 @@
|
|||||||
'outcome' => 'pending',
|
'outcome' => 'pending',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
|
||||||
|
|
||||||
|
$tenantB->forceFill(['workspace_id' => (int) $tenantA->workspace_id])->save();
|
||||||
$user->tenants()->syncWithoutDetaching([
|
$user->tenants()->syncWithoutDetaching([
|
||||||
$tenantA->getKey() => ['role' => 'owner'],
|
|
||||||
$tenantB->getKey() => ['role' => 'owner'],
|
$tenantB->getKey() => ['role' => 'owner'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -49,9 +49,10 @@
|
|||||||
'outcome' => 'pending',
|
'outcome' => 'pending',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
|
||||||
|
|
||||||
|
$tenantB->forceFill(['workspace_id' => (int) $tenantA->workspace_id])->save();
|
||||||
$user->tenants()->syncWithoutDetaching([
|
$user->tenants()->syncWithoutDetaching([
|
||||||
$tenantA->getKey() => ['role' => 'owner'],
|
|
||||||
$tenantB->getKey() => ['role' => 'owner'],
|
$tenantB->getKey() => ['role' => 'owner'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -70,10 +71,7 @@
|
|||||||
'outcome' => 'pending',
|
'outcome' => 'pending',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly');
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => ['role' => 'readonly'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(OperationRunResource::getUrl('index', tenant: $tenant))
|
->get(OperationRunResource::getUrl('index', tenant: $tenant))
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
Http::preventStrayRequests();
|
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();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
$workspace = Workspace::factory()->create(['slug' => 'acme']);
|
$workspace = Workspace::factory()->create(['slug' => 'acme']);
|
||||||
@ -30,7 +30,7 @@
|
|||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||||
->get('/admin/managed-tenants')
|
->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 {
|
it('keeps the managed-tenants landing tenantless even if the user has a tenant in another workspace', function (): void {
|
||||||
|
|||||||
@ -24,7 +24,7 @@
|
|||||||
$this
|
$this
|
||||||
->actingAs($user)
|
->actingAs($user)
|
||||||
->post(route('admin.switch-workspace'), ['workspace_id' => (int) $workspace->getKey()])
|
->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());
|
expect(session(WorkspaceContext::SESSION_KEY))->toBe((int) $workspace->getKey());
|
||||||
});
|
});
|
||||||
|
|||||||
@ -88,11 +88,26 @@ function assertNoOutboundHttp(Closure $callback): mixed
|
|||||||
/**
|
/**
|
||||||
* @return array{0: User, 1: Tenant}
|
* @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();
|
$user ??= User::factory()->create();
|
||||||
$tenant ??= Tenant::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;
|
$workspace = null;
|
||||||
|
|
||||||
if ($tenant->workspace_id !== null) {
|
if ($tenant->workspace_id !== null) {
|
||||||
@ -107,11 +122,11 @@ function createUserWithTenant(?Tenant $tenant = null, ?User $user = null, string
|
|||||||
])->save();
|
])->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
WorkspaceMembership::query()->firstOrCreate([
|
WorkspaceMembership::query()->updateOrCreate([
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
'user_id' => (int) $user->getKey(),
|
'user_id' => (int) $user->getKey(),
|
||||||
], [
|
], [
|
||||||
'role' => 'owner',
|
'role' => $workspaceRole,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|||||||
@ -79,7 +79,7 @@ function restoreIntuneTenantId(string|false $original): void
|
|||||||
'is_current' => false,
|
'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);
|
restoreIntuneTenantId($originalEnv);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user