feat/031-tenant-portfolio-context-switch (#32)

Tenant Switch implemented

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #32
This commit is contained in:
ahmido 2026-01-04 21:28:08 +00:00
parent 817ad208da
commit 2ca989c00f
72 changed files with 1608 additions and 101 deletions

View File

@ -0,0 +1,83 @@
<?php
namespace App\Filament\Pages\Tenancy;
use App\Models\Tenant;
use App\Models\User;
use App\Support\TenantRole;
use Filament\Forms;
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
use Filament\Schemas\Schema;
use Illuminate\Database\Eloquent\Model;
class RegisterTenant extends BaseRegisterTenant
{
public static function getLabel(): string
{
return 'Register tenant';
}
public static function canView(): bool
{
return true;
}
public function form(Schema $schema): Schema
{
return $schema
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255),
Forms\Components\Select::make('environment')
->options([
'prod' => 'PROD',
'dev' => 'DEV',
'staging' => 'STAGING',
'other' => 'Other',
])
->default('other')
->required(),
Forms\Components\TextInput::make('tenant_id')
->label('Tenant ID (GUID)')
->required()
->maxLength(255)
->unique(ignoreRecord: true),
Forms\Components\TextInput::make('domain')
->label('Primary domain')
->maxLength(255),
Forms\Components\TextInput::make('app_client_id')
->label('App Client ID')
->maxLength(255),
Forms\Components\TextInput::make('app_client_secret')
->label('App Client Secret')
->password()
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
->dehydrated(fn ($state) => filled($state)),
Forms\Components\TextInput::make('app_certificate_thumbprint')
->label('Certificate thumbprint')
->maxLength(255),
Forms\Components\Textarea::make('app_notes')
->label('Notes')
->rows(3),
]);
}
/**
* @param array<string, mixed> $data
*/
protected function handleRegistration(array $data): Model
{
$tenant = Tenant::create($data);
$user = auth()->user();
if ($user instanceof User) {
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => TenantRole::Owner->value],
]);
}
return $tenant;
}
}

View File

@ -112,8 +112,16 @@ public function table(Table $table): Table
Actions\ActionGroup::make([ Actions\ActionGroup::make([
Actions\ViewAction::make() Actions\ViewAction::make()
->label('View policy') ->label('View policy')
->url(fn ($record) => $record->policy_id ? PolicyResource::getUrl('view', ['record' => $record->policy_id]) : null) ->url(function (BackupItem $record): ?string {
->hidden(fn ($record) => ! $record->policy_id) if (! $record->policy_id) {
return null;
}
$tenant = $this->getOwnerRecord()->tenant ?? \App\Models\Tenant::current();
return PolicyResource::getUrl('view', ['record' => $record->policy_id], tenant: $tenant);
})
->hidden(fn (BackupItem $record) => ! $record->policy_id)
->openUrlInNewTab(true), ->openUrlInNewTab(true),
Actions\Action::make('remove') Actions\Action::make('remove')
->label('Remove') ->label('Remove')

View File

@ -186,9 +186,7 @@ public static function table(Table $table): Table
->falseLabel('Archived'), ->falseLabel('Archived'),
]) ])
->actions([ ->actions([
Actions\ViewAction::make() Actions\ViewAction::make(),
->url(fn (PolicyVersion $record) => static::getUrl('view', ['record' => $record]))
->openUrlInNewTab(false),
Actions\ActionGroup::make([ Actions\ActionGroup::make([
Actions\Action::make('restore_via_wizard') Actions\Action::make('restore_via_wizard')
->label('Restore via Wizard') ->label('Restore via Wizard')

View File

@ -4,13 +4,18 @@
use App\Filament\Resources\TenantResource\Pages; use App\Filament\Resources\TenantResource\Pages;
use App\Http\Controllers\RbacDelegatedAuthController; use App\Http\Controllers\RbacDelegatedAuthController;
use App\Jobs\BulkTenantSyncJob;
use App\Jobs\SyncPoliciesJob;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Intune\RbacHealthService; use App\Services\Intune\RbacHealthService;
use App\Services\Intune\RbacOnboardingService; use App\Services\Intune\RbacOnboardingService;
use App\Services\Intune\TenantConfigService; use App\Services\Intune\TenantConfigService;
use App\Services\Intune\TenantPermissionService; use App\Services\Intune\TenantPermissionService;
use App\Support\TenantRole;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
@ -23,6 +28,8 @@
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -33,6 +40,8 @@ class TenantResource extends Resource
{ {
protected static ?string $model = Tenant::class; protected static ?string $model = Tenant::class;
protected static bool $isScopedToTenant = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
protected static string|UnitEnum|null $navigationGroup = 'Settings'; protected static string|UnitEnum|null $navigationGroup = 'Settings';
@ -44,6 +53,15 @@ public static function form(Schema $schema): Schema
Forms\Components\TextInput::make('name') Forms\Components\TextInput::make('name')
->required() ->required()
->maxLength(255), ->maxLength(255),
Forms\Components\Select::make('environment')
->options([
'prod' => 'PROD',
'dev' => 'DEV',
'staging' => 'STAGING',
'other' => 'Other',
])
->default('other')
->required(),
Forms\Components\TextInput::make('tenant_id') Forms\Components\TextInput::make('tenant_id')
->label('Tenant ID (GUID)') ->label('Tenant ID (GUID)')
->required() ->required()
@ -69,10 +87,28 @@ public static function form(Schema $schema): Schema
]); ]);
} }
public static function getEloquentQuery(): Builder
{
$user = auth()->user();
if (! $user instanceof User) {
return parent::getEloquentQuery()->whereRaw('1 = 0');
}
$tenantIds = $user->tenants()
->withTrashed()
->pluck('tenants.id');
return parent::getEloquentQuery()
->withTrashed()
->whereIn('id', $tenantIds)
->withCount('policies')
->withMax('policies as last_policy_sync_at', 'last_synced_at');
}
public static function table(Table $table): Table public static function table(Table $table): Table
{ {
return $table return $table
->modifyQueryUsing(fn (\Illuminate\Database\Eloquent\Builder $query) => $query->withTrashed())
->columns([ ->columns([
Tables\Columns\TextColumn::make('name') Tables\Columns\TextColumn::make('name')
->searchable(), ->searchable(),
@ -80,6 +116,23 @@ public static function table(Table $table): Table
->label('Tenant ID') ->label('Tenant ID')
->copyable() ->copyable()
->searchable(), ->searchable(),
Tables\Columns\TextColumn::make('environment')
->badge()
->color(fn (?string $state) => match ($state) {
'prod' => 'danger',
'dev' => 'warning',
'staging' => 'info',
default => 'gray',
})
->sortable(),
Tables\Columns\TextColumn::make('policies_count')
->label('Policies')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('last_policy_sync_at')
->label('Last Sync')
->since()
->sortable(),
Tables\Columns\TextColumn::make('domain') Tables\Columns\TextColumn::make('domain')
->copyable() ->copyable()
->toggleable(), ->toggleable(),
@ -102,6 +155,13 @@ public static function table(Table $table): Table
->trueLabel('All') ->trueLabel('All')
->falseLabel('Archived') ->falseLabel('Archived')
->default(true), ->default(true),
Tables\Filters\SelectFilter::make('environment')
->options([
'prod' => 'PROD',
'dev' => 'DEV',
'staging' => 'STAGING',
'other' => 'Other',
]),
Tables\Filters\SelectFilter::make('app_status') Tables\Filters\SelectFilter::make('app_status')
->options([ ->options([
'ok' => 'OK', 'ok' => 'OK',
@ -113,6 +173,51 @@ public static function table(Table $table): Table
->actions([ ->actions([
Actions\ViewAction::make(), Actions\ViewAction::make(),
ActionGroup::make([ ActionGroup::make([
Actions\Action::make('syncTenant')
->label('Sync')
->icon('heroicon-o-arrow-path')
->color('warning')
->requiresConfirmation()
->visible(function (Tenant $record): bool {
if (! $record->isActive()) {
return false;
}
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->canSyncTenant($record);
})
->action(function (Tenant $record, AuditLogger $auditLogger): void {
SyncPoliciesJob::dispatch($record->getKey());
$auditLogger->log(
tenant: $record,
action: 'tenant.sync_dispatched',
resourceType: 'tenant',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $record->tenant_id]],
);
Notification::make()
->title('Sync started')
->body("Sync dispatched for {$record->name}.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->success()
->sendToDatabase(auth()->user())
->send();
}),
Actions\Action::make('openTenant')
->label('Open')
->icon('heroicon-o-arrow-right')
->color('primary')
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record))
->visible(fn (Tenant $record) => $record->isActive()),
Actions\EditAction::make(), Actions\EditAction::make(),
Actions\RestoreAction::make() Actions\RestoreAction::make()
->label('Restore') ->label('Restore')
@ -242,7 +347,106 @@ public static function table(Table $table): Table
}), }),
])->icon('heroicon-o-ellipsis-vertical'), ])->icon('heroicon-o-ellipsis-vertical'),
]) ])
->bulkActions([]) ->bulkActions([
Actions\BulkAction::make('syncSelected')
->label('Sync selected')
->icon('heroicon-o-arrow-path')
->color('warning')
->requiresConfirmation()
->visible(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->tenants()
->whereIn('role', [
TenantRole::Owner->value,
TenantRole::Manager->value,
TenantRole::Operator->value,
])
->exists();
})
->authorize(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->tenants()
->whereIn('role', [
TenantRole::Owner->value,
TenantRole::Manager->value,
TenantRole::Operator->value,
])
->exists();
})
->action(function (Collection $records, AuditLogger $auditLogger): void {
$user = auth()->user();
if (! $user instanceof User) {
return;
}
$eligible = $records
->filter(fn ($record) => $record instanceof Tenant && $record->isActive())
->filter(fn (Tenant $tenant) => $user->canSyncTenant($tenant));
if ($eligible->isEmpty()) {
Notification::make()
->title('Bulk sync skipped')
->body('No eligible tenants selected.')
->icon('heroicon-o-information-circle')
->info()
->sendToDatabase($user)
->send();
return;
}
$tenantContext = Tenant::current() ?? $eligible->first();
if (! $tenantContext) {
return;
}
$ids = $eligible->pluck('id')->toArray();
$count = $eligible->count();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenantContext, $user, 'tenant', 'sync', $ids, $count);
foreach ($eligible as $tenant) {
SyncPoliciesJob::dispatch($tenant->getKey());
$auditLogger->log(
tenant: $tenant,
action: 'tenant.sync_dispatched',
resourceType: 'tenant',
resourceId: (string) $tenant->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $tenant->tenant_id]],
);
}
$count = $eligible->count();
Notification::make()
->title('Bulk sync started')
->body("Syncing {$count} tenant(s) in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->success()
->duration(8000)
->sendToDatabase($user)
->send();
BulkTenantSyncJob::dispatch($run->id);
})
->deselectRecordsAfterCompletion(),
])
->headerActions([]); ->headerActions([]);
} }
@ -440,7 +644,10 @@ public static function rbacAction(): Actions\Action
->label('Open RBAC login') ->label('Open RBAC login')
->url(route('admin.rbac.start', [ ->url(route('admin.rbac.start', [
'tenant' => $record->graphTenantId(), 'tenant' => $record->graphTenantId(),
'return' => route('filament.admin.resources.tenants.view', $record), 'return' => route('filament.admin.resources.tenants.view', [
'tenant' => $record->external_id,
'record' => $record,
]),
])), ])),
]) ])
->warning() ->warning()
@ -579,7 +786,10 @@ private static function loginToSearchRolesAction(?Tenant $tenant): ?Actions\Acti
->label('Login to load roles') ->label('Login to load roles')
->url(route('admin.rbac.start', [ ->url(route('admin.rbac.start', [
'tenant' => $tenant->graphTenantId(), 'tenant' => $tenant->graphTenantId(),
'return' => route('filament.admin.resources.tenants.view', $tenant), 'return' => route('filament.admin.resources.tenants.view', [
'tenant' => $tenant->external_id,
'record' => $tenant,
]),
])); ]));
} }
@ -761,7 +971,10 @@ private static function loginToSearchGroupsAction(?Tenant $tenant): ?Actions\Act
->label('Login to search groups') ->label('Login to search groups')
->url(route('admin.rbac.start', [ ->url(route('admin.rbac.start', [
'tenant' => $tenant->graphTenantId(), 'tenant' => $tenant->graphTenantId(),
'return' => route('filament.admin.resources.tenants.view', $tenant), 'return' => route('filament.admin.resources.tenants.view', [
'tenant' => $tenant->external_id,
'record' => $tenant,
]),
])); ]));
} }

View File

@ -3,9 +3,24 @@
namespace App\Filament\Resources\TenantResource\Pages; namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource; use App\Filament\Resources\TenantResource;
use App\Models\User;
use App\Support\TenantRole;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
class CreateTenant extends CreateRecord class CreateTenant extends CreateRecord
{ {
protected static string $resource = TenantResource::class; protected static string $resource = TenantResource::class;
protected function afterCreate(): void
{
$user = auth()->user();
if (! $user instanceof User) {
return;
}
$user->tenants()->syncWithoutDetaching([
$this->record->getKey() => ['role' => TenantRole::Owner->value],
]);
}
} }

View File

@ -0,0 +1,152 @@
<?php
namespace App\Jobs;
use App\Models\BulkOperationRun;
use App\Models\Tenant;
use App\Services\BulkOperationService;
use App\Services\Intune\PolicySyncService;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
class BulkTenantSyncJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public int $bulkRunId) {}
public function handle(BulkOperationService $service, PolicySyncService $syncService): void
{
$run = BulkOperationRun::with(['tenant', 'user'])->find($this->bulkRunId);
if (! $run || $run->status !== 'pending') {
return;
}
$service->start($run);
try {
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
$itemCount = 0;
$supported = config('tenantpilot.supported_policy_types');
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
$failureThreshold = (int) floor($totalItems / 2);
foreach (($run->item_ids ?? []) as $tenantId) {
$itemCount++;
try {
$tenant = Tenant::query()->whereKey($tenantId)->first();
if (! $tenant) {
$service->recordFailure($run, (string) $tenantId, 'Tenant not found');
if ($run->failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($run->user) {
Notification::make()
->title('Bulk Sync Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->send();
}
return;
}
continue;
}
if (! $tenant->isActive()) {
$service->recordSkippedWithReason($run, (string) $tenantId, 'Tenant is not active');
continue;
}
if (! $run->user || ! $run->user->canSyncTenant($tenant)) {
$service->recordSkippedWithReason($run, (string) $tenantId, 'Not authorized to sync tenant');
continue;
}
$syncService->syncPolicies($tenant, $supported);
$service->recordSuccess($run);
} catch (Throwable $e) {
$service->recordFailure($run, (string) $tenantId, $e->getMessage());
if ($run->failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($run->user) {
Notification::make()
->title('Bulk Sync Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->send();
}
return;
}
}
if ($itemCount % $chunkSize === 0) {
$run->refresh();
}
}
$service->complete($run);
if ($run->user) {
$message = "Synced {$run->succeeded} tenant(s)";
if ($run->skipped > 0) {
$message .= " ({$run->skipped} skipped)";
}
if ($run->failed > 0) {
$message .= " ({$run->failed} failed)";
}
$message .= '.';
Notification::make()
->title('Bulk Sync Completed')
->body($message)
->icon('heroicon-o-check-circle')
->success()
->sendToDatabase($run->user)
->send();
}
} catch (Throwable $e) {
$service->fail($run, $e->getMessage());
$run->refresh();
$run->load('user');
if ($run->user) {
Notification::make()
->title('Bulk Sync Failed')
->body($e->getMessage())
->icon('heroicon-o-x-circle')
->danger()
->sendToDatabase($run->user)
->send();
}
throw $e;
}
}
}

View File

@ -2,16 +2,19 @@
namespace App\Models; namespace App\Models;
use Filament\Facades\Filament;
use Filament\Models\Contracts\HasName;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use RuntimeException; use RuntimeException;
class Tenant extends Model class Tenant extends Model implements HasName
{ {
use HasFactory; use HasFactory;
use SoftDeletes; use SoftDeletes;
@ -114,6 +117,12 @@ public function makeCurrent(): void
public static function current(): self public static function current(): self
{ {
$filamentTenant = Filament::getTenant();
if ($filamentTenant instanceof self) {
return $filamentTenant;
}
$envTenantId = getenv('INTUNE_TENANT_ID') ?: null; $envTenantId = getenv('INTUNE_TENANT_ID') ?: null;
if ($envTenantId) { if ($envTenantId) {
@ -142,6 +151,20 @@ public static function current(): self
return $tenant; return $tenant;
} }
public function getFilamentName(): string
{
$environment = strtoupper((string) ($this->environment ?? 'other'));
return "{$this->name} ({$environment})";
}
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class)
->withPivot('role')
->withTimestamps();
}
public function policies(): HasMany public function policies(): HasMany
{ {
return $this->hasMany(Policy::class); return $this->hasMany(Policy::class);

View File

@ -2,13 +2,21 @@
namespace App\Models; namespace App\Models;
use App\Support\TenantRole;
use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\FilamentUser;
use Filament\Models\Contracts\HasDefaultTenant;
use Filament\Models\Contracts\HasTenants;
use Filament\Panel; use Filament\Panel;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
class User extends Authenticatable implements FilamentUser class User extends Authenticatable implements FilamentUser, HasDefaultTenant, HasTenants
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ /** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable; use HasFactory, Notifiable;
@ -51,4 +59,113 @@ public function canAccessPanel(Panel $panel): bool
{ {
return true; return true;
} }
public function tenants(): BelongsToMany
{
return $this->belongsToMany(Tenant::class)
->withPivot('role')
->withTimestamps();
}
public function tenantPreferences(): HasMany
{
return $this->hasMany(UserTenantPreference::class);
}
private function tenantPivotTableExists(): bool
{
static $exists;
return $exists ??= Schema::hasTable('tenant_user');
}
private function tenantPreferencesTableExists(): bool
{
static $exists;
return $exists ??= Schema::hasTable('user_tenant_preferences');
}
public function tenantRole(Tenant $tenant): ?TenantRole
{
if (! $this->tenantPivotTableExists()) {
return null;
}
$role = $this->tenants()
->whereKey($tenant->getKey())
->value('role');
if (! is_string($role)) {
return null;
}
return TenantRole::tryFrom($role);
}
public function canSyncTenant(Tenant $tenant): bool
{
$role = $this->tenantRole($tenant);
return $role?->canSync() ?? false;
}
public function canAccessTenant(Model $tenant): bool
{
if (! $tenant instanceof Tenant) {
return false;
}
if (! $this->tenantPivotTableExists()) {
return false;
}
return $this->tenants()
->whereKey($tenant->getKey())
->exists();
}
public function getTenants(Panel $panel): array|Collection
{
if (! $this->tenantPivotTableExists()) {
return collect();
}
return $this->tenants()
->where('status', 'active')
->orderBy('name')
->get();
}
public function getDefaultTenant(Panel $panel): ?Model
{
if (! $this->tenantPivotTableExists()) {
return null;
}
$tenantId = null;
if ($this->tenantPreferencesTableExists()) {
$tenantId = $this->tenantPreferences()
->whereNotNull('last_used_at')
->orderByDesc('last_used_at')
->value('tenant_id');
}
if ($tenantId !== null) {
$tenant = $this->tenants()
->where('status', 'active')
->whereKey($tenantId)
->first();
if ($tenant !== null) {
return $tenant;
}
}
return $this->tenants()
->where('status', 'active')
->orderBy('name')
->first();
}
} }

View File

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserTenantPreference extends Model
{
protected $guarded = [];
protected $casts = [
'is_favorite' => 'boolean',
'last_used_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}

View File

@ -2,6 +2,9 @@
namespace App\Providers; namespace App\Providers;
use App\Models\Tenant;
use App\Models\User;
use App\Models\UserTenantPreference;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\MicrosoftGraphClient; use App\Services\Graph\MicrosoftGraphClient;
use App\Services\Graph\NullGraphClient; use App\Services\Graph\NullGraphClient;
@ -18,6 +21,9 @@
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer; use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer; use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
use App\Services\Intune\WindowsUpdateRingNormalizer; use App\Services\Intune\WindowsUpdateRingNormalizer;
use Filament\Events\TenantSet;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@ -66,6 +72,35 @@ public function register(): void
*/ */
public function boot(): void public function boot(): void
{ {
// Event::listen(TenantSet::class, function (TenantSet $event): void {
static $hasPreferencesTable;
$hasPreferencesTable ??= Schema::hasTable('user_tenant_preferences');
if (! $hasPreferencesTable) {
return;
}
$tenant = $event->getTenant();
$user = $event->getUser();
if (! $tenant instanceof Tenant) {
return;
}
if (! $user instanceof User) {
return;
}
UserTenantPreference::query()->updateOrCreate(
[
'user_id' => $user->getKey(),
'tenant_id' => $tenant->getKey(),
],
[
'last_used_at' => now(),
],
);
});
} }
} }

View File

@ -2,6 +2,8 @@
namespace App\Providers\Filament; namespace App\Providers\Filament;
use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Models\Tenant;
use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DisableBladeIconComponents;
@ -29,6 +31,10 @@ public function panel(Panel $panel): Panel
->id('admin') ->id('admin')
->path('admin') ->path('admin')
->login() ->login()
->tenant(Tenant::class, slugAttribute: 'external_id')
->tenantRoutePrefix('t')
->searchableTenantMenu()
->tenantRegistration(RegisterTenant::class)
->colors([ ->colors([
'primary' => Color::Amber, 'primary' => Color::Amber,
]) ])

View File

@ -0,0 +1,21 @@
<?php
namespace App\Support;
enum TenantRole: string
{
case Owner = 'owner';
case Manager = 'manager';
case Operator = 'operator';
case Readonly = 'readonly';
public function canSync(): bool
{
return match ($this) {
self::Owner,
self::Manager,
self::Operator => true,
self::Readonly => false,
};
}
}

View File

@ -26,6 +26,7 @@ public function definition(): array
'app_status' => 'ok', 'app_status' => 'ok',
'app_notes' => null, 'app_notes' => null,
'status' => 'active', 'status' => 'active',
'environment' => 'other',
'is_current' => false, 'is_current' => false,
'metadata' => [], 'metadata' => [],
]; ];

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->string('environment')->default('other')->after('status');
$table->index('environment');
});
}
public function down(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->dropIndex(['environment']);
$table->dropColumn('environment');
});
}
};

View File

@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('tenant_user', function (Blueprint $table) {
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('role')->default('owner');
$table->timestamps();
$table->unique(['tenant_id', 'user_id']);
});
$now = now();
$tenantIds = DB::table('tenants')
->whereNull('deleted_at')
->pluck('id');
$userIds = DB::table('users')->pluck('id');
if ($tenantIds->isEmpty() || $userIds->isEmpty()) {
return;
}
$rows = [];
foreach ($tenantIds as $tenantId) {
foreach ($userIds as $userId) {
$rows[] = [
'tenant_id' => $tenantId,
'user_id' => $userId,
'role' => 'owner',
'created_at' => $now,
'updated_at' => $now,
];
if (count($rows) >= 500) {
DB::table('tenant_user')->insertOrIgnore($rows);
$rows = [];
}
}
}
if ($rows !== []) {
DB::table('tenant_user')->insertOrIgnore($rows);
}
}
public function down(): void
{
Schema::dropIfExists('tenant_user');
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('user_tenant_preferences', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->boolean('is_favorite')->default(false);
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
$table->unique(['user_id', 'tenant_id']);
$table->index(['user_id', 'last_used_at']);
});
}
public function down(): void
{
Schema::dropIfExists('user_tenant_preferences');
}
};

View File

@ -31,7 +31,11 @@
<p>Admin consent wurde bestätigt.</p> <p>Admin consent wurde bestätigt.</p>
@endif @endif
<p><a href="{{ route('filament.admin.resources.tenants.view', $tenant) }}">Zurück zur Tenant-Detailseite</a></p> <p>
<a href="{{ route('filament.admin.resources.tenants.view', ['tenant' => $tenant->external_id, 'record' => $tenant]) }}">
Zurück zur Tenant-Detailseite
</a>
</p>
</div> </div>
</body> </body>
</html> </html>

View File

@ -0,0 +1,13 @@
# Requirements Checklist (031)
**Created**: 2026-01-04
**Feature**: [spec.md](../spec.md)
- [x] Tenant memberships/roles exist and are enforced.
- [x] Current Tenant context is per-user and always visible.
- [x] Portfolio shows only accessible tenants with environment + health/status.
- [x] “Open tenant” changes context and redirects into tenant-scoped area.
- [x] Tenant-scoped resources are filtered by context and deny unauthorized access.
- [x] Bulk “Sync selected” dispatches per-tenant jobs and is role-gated.
- [x] Restore flows show target tenant + environment and require tenant-aware confirmation.
- [x] Pest tests cover authorization + context switching + bulk actions.

View File

@ -0,0 +1,34 @@
# Plan: Tenant Portfolio & Context Switch (031)
**Branch**: `feat/031-tenant-portfolio-context-switch`
**Date**: 2026-01-04
**Input**: [spec.md](./spec.md)
## Approach
1. Decide on the tenant context mechanism:
- Preferred: Filament tenancy (tenant in URL) + built-in tenant switcher.
- Fallback: session-based Current Tenant + visible banner (avoid global/DB state as “source of truth”).
2. Add data model pieces:
- tenant membership/role mapping
- tenant environment attribute
- optional user preferences (favorites + last used)
3. Implement a single TenantContext resolver (HTTP + console) and central authorization gate/policy:
- deny-by-default if no access
- keep `INTUNE_TENANT_ID` as console override for automation
4. Update tenant-scoped resources/services to use TenantContext instead of `Tenant::current()` and ensure base queries are tenant-scoped.
5. Extend `TenantResource` into a portfolio view:
- access-scoped query
- environment/health columns
- “Open” action + “Sync” action
- bulk “Sync selected”
6. Add restore guardrails:
- target tenant badge/header on restore pages
- type-to-confirm includes tenant/environment (e.g. `RESTORE PROD`)
7. Add targeted Pest tests for authorization, context switching, and bulk sync.
8. Run Pint + targeted tests; document rollout/migration notes.
## Decisions / Notes
- Avoid a global `tenants.is_current` UI context (unsafe for MSP); prefer per-user context.
- Avoid storing Current Tenant in the `users` table as the source of truth (cross-tab risk); prefer route/session context, optionally persisting “last used” separately.
- Start with user-based tenant memberships; extend to organization/group principals later if needed.
- Prefer deriving portfolio stats via relationships (`withCount`, `withMax`) initially; add denormalized summary columns only if needed for performance.

View File

@ -0,0 +1,89 @@
# Feature Specification: Tenant Portfolio & Context Switch (031)
**Feature Branch**: `feat/031-tenant-portfolio-context-switch`
**Created**: 2026-01-04
**Status**: Implemented (ready to merge)
**Risk**: Medium
**Priority**: P1
## Context
Today TenantPilot behaves like a single-tenant app:
- The “current tenant” is global (`tenants.is_current` + `Tenant::current()`), not per user.
- Most tenant-scoped screens implicitly use `Tenant::current()`.
This is limiting and potentially unsafe for:
- Customers running multiple tenants (PROD/DEV/STAGING).
- MSPs managing many customer tenants.
We need a tenant-agnostic **Portfolio** view plus an explicit, always-visible **Current Tenant** context for all tenant-scoped areas (Policies, Backups, Restore Runs, etc.).
## Design Considerations (Best Practice)
- Prefer an explicit tenant context (route parameter or session) over hidden global state.
- Avoid storing Current Tenant in the `users` table as the source of truth (cross-tab risk). If persistence is needed, store **“last used”** separately and treat it as a default for new sessions.
- Keep console/automation behavior stable: `INTUNE_TENANT_ID` can remain a console override, but tenant-scoped UI must not depend on it.
## User Scenarios & Testing
### User Story 1 — Portfolio overview (P1)
As a user with access to multiple tenants, I can see a portfolio overview with health/status and key counts.
**Acceptance Scenarios**
1. Tenants list shows only tenants the user can access.
2. Portfolio shows environment badge (PROD/DEV/STAGING/OTHER) and connection/health indicators.
3. Portfolio columns can be filtered by environment and connection status.
### User Story 2 — Safe tenant context switching (P1)
As a user, I can switch the Current Tenant via a topbar switcher or by clicking “Open” in the portfolio, and all tenant-scoped screens reflect that tenant.
**Acceptance Scenarios**
1. Switching tenant updates the visible Current Tenant badge and redirects to a default tenant-scoped landing page (e.g. Policies).
2. Policies, Backups, Restore Runs, and Policy Versions are scoped to the selected tenant.
3. Restore flows always show the target tenant and environment prominently and require tenant-aware type-to-confirm.
### User Story 3 — Multi-tenant bulk actions (P2)
As an operator, I can select multiple tenants in the portfolio and run safe bulk actions (initially Sync).
**Acceptance Scenarios**
1. Bulk “Sync selected” dispatches a sync job per tenant (batch) and shows progress.
2. Readonly users cannot trigger bulk sync.
### User Story 4 — Authorization hardening (P1)
As a user, I cannot access tenants or tenant-scoped data I am not authorized for.
**Acceptance Scenarios**
1. Attempting to open a tenant without access is denied (403) and does not change Current Tenant.
2. Direct URL access to tenant-scoped pages for an unauthorized tenant returns 403/404.
## Requirements
### Functional Requirements
- **FR-001**: Introduce a per-user Current Tenant context for all tenant-scoped screens.
- **FR-002**: Current Tenant context must be always visible in the UI (topbar) to reduce “wrong tenant” operations.
- **FR-003**: Add an “Open” action from the portfolio to set Current Tenant and redirect into the tenant-scoped area.
- **FR-004**: Portfolio view is tenant-agnostic and supports filtering, search, and safe bulk actions.
- **FR-005**: Tenant access is enforced centrally (single `canAccessTenant(...)` gate/policy used by UI + routes + services).
- **FR-006**: Restore remains single-tenant; restore actions must include explicit tenant/environment confirmations and never rely on hidden global context.
- **FR-007**: Bulk Sync is tenant-safe: per-tenant authorization, per-tenant job execution, and audit logs for each tenant sync trigger.
### UX / UI Requirements
- **UX-001**: Topbar shows “Tenant: <name>” with an environment badge (PROD/DEV/STAGING/OTHER) and is accessible from all tenant-scoped pages.
- **UX-002**: Tenant switcher is searchable (typeahead); favorites (if enabled) appear at the top.
- **UX-003**: Portfolio table includes (at minimum): Name, Tenant ID (short/copy), Environment, Connection/App status, RBAC/Health indicator, Last Sync (time), Policies count; optional Restore runs (last 30d).
- **UX-004**: Portfolio “Open” action makes the tenant context explicit and navigates into the tenant-scoped area.
- **UX-005**: Restore screens show “Target Tenant” prominently (name + environment badge) and require tenant-aware type-to-confirm (e.g. `RESTORE PROD`).
### Data Model Requirements
- **DM-001**: Introduce tenant access/membership mapping (user ↔ tenant) with a role (`owner|manager|operator|readonly`).
- **DM-002**: Add tenant environment classification (`prod|dev|staging|other`) as a first-class attribute (column or indexed JSONB).
- **DM-003 (Optional)**: Persist per-user tenant preferences (favorites + last used) without coupling it to cross-tab safety.
- **DM-004 (Optional)**: Support grouping tenants by customer (MSP use case) via a lightweight “customer label” or a dedicated Customer model (future).
## Non-Goals
- No multi-tenant policy detail view in one screen.
- No multi-tenant restore; restore run always targets exactly one tenant.
- No cross-tenant diff/promotion (separate feature).
## Success Criteria
- **SC-001**: A user can switch Current Tenant quickly and always understands which tenant they are operating on.
- **SC-002**: All tenant-scoped data is strictly filtered and authorization-safe.
- **SC-003**: Bulk Sync works across selected tenants with clear feedback and role gating.

View File

@ -0,0 +1,33 @@
# Tasks: Tenant Portfolio & Context Switch (031)
**Branch**: `feat/031-tenant-portfolio-context-switch`
**Date**: 2026-01-04
**Input**: [spec.md](./spec.md), [plan.md](./plan.md)
## Phase 1: Setup
- [x] T001 Create spec/plan/tasks and checklist.
## Phase 2: Research & Design
- [x] T002 Review Filament tenancy support and choose the context mechanism (route vs session).
- [x] T003 Define tenant access roles and mapping (user memberships; future org/group principals).
- [x] T004 Decide how to store `environment` (column vs JSONB) and whether MSP “customer grouping” is in scope.
- [x] T005 Define context precedence rules (env override, route tenant, session/default tenant) and cross-tab safety expectations.
## Phase 3: Tests (TDD)
- [x] T006 Authorization: user cannot access unauthorized tenant (404).
- [x] T007 Authorization: tenant-scoped resources deny cross-tenant access via URL (404).
- [x] T008 Context switching: “Open tenant” navigates into tenant-scoped pages (tenant in URL) and data filters correctly.
- [x] T009 Bulk sync: dispatches one job per selected tenant; readonly role cannot run it.
- [ ] T010 UI (optional browser tests): tenant switcher visible and environment badge shown.
## Phase 4: Implementation
- [x] T011 Add migrations for tenant memberships/roles and environment attribute (and optional preferences).
- [x] T012 Implement `TenantContext` + authorization gate/policy (`canAccessTenant`).
- [x] T013 Integrate tenant switcher into Filament topbar and make Current Tenant always visible.
- [x] T014 Scope tenant resources (Policies/Backups/RestoreRuns/etc.) via TenantContext; replace direct `Tenant::current()` usage.
- [x] T015 Update `TenantResource` into a portfolio view: access-scoped query, columns, filters, “Open”, “Sync”, bulk “Sync selected”.
- [x] T016 Add restore guardrails (target tenant header + tenant-aware confirmations).
## Phase 5: Verification
- [x] T017 Run targeted tests.
- [x] T018 Run Pint (`./vendor/bin/pint --dirty`).

View File

@ -7,6 +7,7 @@
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
@ -14,8 +15,12 @@
test('backup sets table bulk archive creates a run and archives selected sets', function () { test('backup sets table bulk archive creates a run and archives selected sets', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$sets = collect(range(1, 3))->map(function (int $i) use ($tenant) { $sets = collect(range(1, 3))->map(function (int $i) use ($tenant) {
return BackupSet::create([ return BackupSet::create([
@ -58,8 +63,12 @@
test('backup sets can be archived even when referenced by restore runs', function () { test('backup sets can be archived even when referenced by restore runs', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$set = BackupSet::create([ $set = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
@ -87,8 +96,12 @@
test('backup sets table bulk archive requires type-to-confirm for 10+ sets', function () { test('backup sets table bulk archive requires type-to-confirm for 10+ sets', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$sets = collect(range(1, 10))->map(function (int $i) use ($tenant) { $sets = collect(range(1, 10))->map(function (int $i) use ($tenant) {
return BackupSet::create([ return BackupSet::create([

View File

@ -6,6 +6,7 @@
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
@ -13,8 +14,12 @@
test('bulk delete restore runs skips running items', function () { test('bulk delete restore runs skips running items', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$backupSet = BackupSet::create([ $backupSet = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,

View File

@ -6,6 +6,7 @@
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
@ -13,8 +14,12 @@
test('bulk delete restore runs soft deletes selected runs', function () { test('bulk delete restore runs soft deletes selected runs', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$backupSet = BackupSet::create([ $backupSet = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,

View File

@ -6,6 +6,7 @@
use App\Models\BulkOperationRun; use App\Models\BulkOperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
@ -13,8 +14,12 @@
test('backup sets table bulk force delete permanently deletes archived sets and their items', function () { test('backup sets table bulk force delete permanently deletes archived sets and their items', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$set = BackupSet::create([ $set = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,

View File

@ -6,6 +6,7 @@
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
@ -14,6 +15,11 @@
test('policy versions table bulk force delete creates a run and skips non-archived records', function () { test('policy versions table bulk force delete creates a run and skips non-archived records', function () {
$tenant = Tenant::factory()->create(['is_current' => true]); $tenant = Tenant::factory()->create(['is_current' => true]);
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]); $policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
$version = PolicyVersion::factory()->create([ $version = PolicyVersion::factory()->create([

View File

@ -6,6 +6,7 @@
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
@ -13,8 +14,12 @@
test('bulk force delete restore runs permanently deletes archived runs', function () { test('bulk force delete restore runs permanently deletes archived runs', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$backupSet = BackupSet::create([ $backupSet = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,

View File

@ -6,6 +6,7 @@
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
@ -14,6 +15,11 @@
test('bulk prune records skip reasons', function () { test('bulk prune records skip reasons', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$policyA = Policy::factory()->create(['tenant_id' => $tenant->id]); $policyA = Policy::factory()->create(['tenant_id' => $tenant->id]);
$current = PolicyVersion::factory()->create([ $current = PolicyVersion::factory()->create([
@ -37,8 +43,6 @@
'captured_at' => now()->subDays(10), 'captured_at' => now()->subDays(10),
]); ]);
$tenant->forceFill(['is_current' => true])->save();
Livewire::actingAs($user) Livewire::actingAs($user)
->test(PolicyVersionResource\Pages\ListPolicyVersions::class) ->test(PolicyVersionResource\Pages\ListPolicyVersions::class)
->callTableBulkAction('bulk_prune_versions', collect([$current, $tooRecent]), data: [ ->callTableBulkAction('bulk_prune_versions', collect([$current, $tooRecent]), data: [

View File

@ -5,6 +5,7 @@
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
@ -13,6 +14,11 @@
test('bulk prune archives eligible policy versions', function () { test('bulk prune archives eligible policy versions', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]); $policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
@ -30,8 +36,6 @@
'captured_at' => now()->subDays(120), 'captured_at' => now()->subDays(120),
]); ]);
$tenant->forceFill(['is_current' => true])->save();
Livewire::actingAs($user) Livewire::actingAs($user)
->test(PolicyVersionResource\Pages\ListPolicyVersions::class) ->test(PolicyVersionResource\Pages\ListPolicyVersions::class)
->callTableBulkAction('bulk_prune_versions', collect([$eligible, $current]), data: [ ->callTableBulkAction('bulk_prune_versions', collect([$eligible, $current]), data: [

View File

@ -6,6 +6,7 @@
use App\Models\BulkOperationRun; use App\Models\BulkOperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
@ -13,8 +14,12 @@
test('backup sets table bulk restore restores archived sets and their items', function () { test('backup sets table bulk restore restores archived sets and their items', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$set = BackupSet::create([ $set = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,

View File

@ -6,6 +6,7 @@
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
@ -14,6 +15,11 @@
test('policy versions table bulk restore creates a run and restores archived records', function () { test('policy versions table bulk restore creates a run and restores archived records', function () {
$tenant = Tenant::factory()->create(['is_current' => true]); $tenant = Tenant::factory()->create(['is_current' => true]);
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]); $policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
$version = PolicyVersion::factory()->create([ $version = PolicyVersion::factory()->create([

View File

@ -6,6 +6,7 @@
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
@ -13,8 +14,12 @@
test('restore runs table bulk restore creates a run and restores archived records', function () { test('restore runs table bulk restore creates a run and restores archived records', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$backupSet = BackupSet::create([ $backupSet = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,

View File

@ -4,6 +4,7 @@
use App\Models\Policy; use App\Models\Policy;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
@ -11,8 +12,12 @@
test('bulk delete requires confirmation string for large batches', function () { test('bulk delete requires confirmation string for large batches', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$policies = Policy::factory()->count(20)->create(['tenant_id' => $tenant->id]); $policies = Policy::factory()->count(20)->create(['tenant_id' => $tenant->id]);
Livewire::actingAs($user) Livewire::actingAs($user)
@ -27,8 +32,12 @@
test('bulk delete fails with incorrect confirmation string', function () { test('bulk delete fails with incorrect confirmation string', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$policies = Policy::factory()->count(20)->create(['tenant_id' => $tenant->id]); $policies = Policy::factory()->count(20)->create(['tenant_id' => $tenant->id]);
Livewire::actingAs($user) Livewire::actingAs($user)
@ -43,8 +52,12 @@
test('bulk delete does not require confirmation string for small batches', function () { test('bulk delete does not require confirmation string for small batches', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$policies = Policy::factory()->count(10)->create(['tenant_id' => $tenant->id]); $policies = Policy::factory()->count(10)->create(['tenant_id' => $tenant->id]);
Livewire::actingAs($user) Livewire::actingAs($user)

View File

@ -47,9 +47,12 @@
]); ]);
$user = User::factory()->create(); $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])); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
$response->assertOk(); $response->assertOk();
$response->assertSee('Data Protection'); $response->assertSee('Data Protection');

View File

@ -22,6 +22,9 @@
$this->tenant = $tenant; $this->tenant = $tenant;
$this->user = User::factory()->create(); $this->user = User::factory()->create();
$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 () {
@ -54,7 +57,7 @@
]); ]);
$response = $this->actingAs($this->user) $response = $this->actingAs($this->user)
->get(PolicyResource::getUrl('view', ['record' => $policy])); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $this->tenant));
$response->assertOk(); $response->assertOk();
$response->assertSee('Settings'); $response->assertSee('Settings');
@ -95,7 +98,7 @@
]); ]);
$response = $this->actingAs($this->user) $response = $this->actingAs($this->user)
->get(PolicyResource::getUrl('view', ['record' => $policy])); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $this->tenant));
$response->assertOk(); $response->assertOk();
$response->assertSee('Settings'); $response->assertSee('Settings');
@ -139,7 +142,7 @@
]); ]);
$response = $this->actingAs($this->user) $response = $this->actingAs($this->user)
->get(PolicyResource::getUrl('view', ['record' => $policy])); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $this->tenant));
$response->assertOk(); $response->assertOk();
$response->assertSee('Settings'); $response->assertSee('Settings');

View File

@ -133,10 +133,13 @@ public function request(string $method, string $path, array $options = []): Grap
); );
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this $response = $this
->actingAs($user) ->actingAs($user)
->get(route('filament.admin.resources.policies.view', ['record' => $policy])); ->get(route('filament.admin.resources.policies.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $policy])));
$response->assertOk(); $response->assertOk();
$response->assertSee('Block legacy auth'); $response->assertSee('Block legacy auth');

View File

@ -12,6 +12,7 @@
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
@ -43,6 +44,10 @@
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(ListBackupSets::class) Livewire::test(ListBackupSets::class)
->callTableAction('archive', $backupSet); ->callTableAction('archive', $backupSet);
@ -78,6 +83,10 @@
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(ListBackupSets::class) Livewire::test(ListBackupSets::class)
->callTableAction('archive', $backupSet); ->callTableAction('archive', $backupSet);
@ -117,6 +126,10 @@
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(ListBackupSets::class) Livewire::test(ListBackupSets::class)
->callTableAction('archive', $backupSet) ->callTableAction('archive', $backupSet)
@ -158,6 +171,10 @@
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(ListBackupSets::class) Livewire::test(ListBackupSets::class)
->callTableAction('archive', $backupSet) ->callTableAction('archive', $backupSet)
@ -197,6 +214,10 @@
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(ListRestoreRuns::class) Livewire::test(ListRestoreRuns::class)
->callTableAction('archive', $restoreRun) ->callTableAction('archive', $restoreRun)
@ -235,6 +256,10 @@
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(ListRestoreRuns::class) Livewire::test(ListRestoreRuns::class)
->callTableAction('archive', $restoreRun) ->callTableAction('archive', $restoreRun)
@ -269,6 +294,10 @@
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(ListPolicies::class) Livewire::test(ListPolicies::class)
->callTableAction('ignore', $policy); ->callTableAction('ignore', $policy);
@ -309,6 +338,10 @@
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(ListPolicyVersions::class) Livewire::test(ListPolicyVersions::class)
->callTableAction('archive', $version); ->callTableAction('archive', $version);
@ -346,6 +379,10 @@
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(ListPolicyVersions::class) Livewire::test(ListPolicyVersions::class)
->callTableAction('archive', $version) ->callTableAction('archive', $version)
@ -368,6 +405,10 @@
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(ListTenants::class) Livewire::test(ListTenants::class)
->callTableAction('archive', $tenant); ->callTableAction('archive', $tenant);
@ -409,6 +450,11 @@
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$active->getKey() => ['role' => 'owner'],
$archived->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($active, true);
$component = Livewire::test(ListTenants::class) $component = Livewire::test(ListTenants::class)
->assertSee($active->name) ->assertSee($active->name)
@ -433,8 +479,18 @@
$tenant->delete(); $tenant->delete();
$contextTenant = Tenant::create([
'tenant_id' => 'tenant-restore-context',
'name' => 'Restore Context Tenant',
]);
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
$contextTenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($contextTenant, true);
Livewire::test(ListTenants::class) Livewire::test(ListTenants::class)
->set('tableFilters.trashed.value', 1) ->set('tableFilters.trashed.value', 1)

View File

@ -41,14 +41,17 @@
]); ]);
$user = User::factory()->create(); $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])); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
$policyResponse->assertSee('This snapshot may be incomplete or malformed'); $policyResponse->assertSee('This snapshot may be incomplete or malformed');
$versionResponse = $this->actingAs($user) $versionResponse = $this->actingAs($user)
->get(PolicyVersionResource::getUrl('view', ['record' => $version])); ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
$versionResponse->assertSee('This snapshot may be incomplete or malformed'); $versionResponse->assertSee('This snapshot may be incomplete or malformed');
}); });

View File

@ -100,9 +100,12 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
]); ]);
$user = User::factory()->create(); $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])); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
$detailResponse->assertSee('@odata.type mismatch'); $detailResponse->assertSee('@odata.type mismatch');

View File

@ -7,6 +7,7 @@
use App\Services\Graph\AssignmentFetcher; use App\Services\Graph\AssignmentFetcher;
use App\Services\Graph\ScopeTagResolver; use App\Services\Graph\ScopeTagResolver;
use App\Services\Intune\PolicySnapshotService; use App\Services\Intune\PolicySnapshotService;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
use Mockery\MockInterface; use Mockery\MockInterface;
@ -22,6 +23,10 @@
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$this->mock(PolicySnapshotService::class, function (MockInterface $mock) use ($policy) { $this->mock(PolicySnapshotService::class, function (MockInterface $mock) use ($policy) {
$mock->shouldReceive('fetch') $mock->shouldReceive('fetch')

View File

@ -7,13 +7,7 @@
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
test('policies are listed for the active tenant', function () { test('policies are listed for the active tenant', function () {
$tenant = Tenant::create([ $tenant = Tenant::factory()->create();
'tenant_id' => 'local-tenant',
'name' => 'Tenant One',
'metadata' => [],
]);
$tenant->makeCurrent();
Policy::create([ Policy::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
@ -24,11 +18,7 @@
'last_synced_at' => now(), 'last_synced_at' => now(),
]); ]);
$otherTenant = Tenant::create([ $otherTenant = Tenant::factory()->create();
'tenant_id' => 'tenant-2',
'name' => 'Tenant Two',
'metadata' => [],
]);
Policy::create([ Policy::create([
'tenant_id' => $otherTenant->id, 'tenant_id' => $otherTenant->id,
@ -40,9 +30,13 @@
]); ]);
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
$otherTenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user) $this->actingAs($user)
->get(route('filament.admin.resources.policies.index')) ->get(route('filament.admin.resources.policies.index', filamentTenantRouteParams($tenant)))
->assertOk() ->assertOk()
->assertSee('Policy A') ->assertSee('Policy A')
->assertDontSee('Policy B'); ->assertDontSee('Policy B');

View File

@ -49,9 +49,12 @@
]); ]);
$user = User::factory()->create(); $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])); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
$response->assertOk(); $response->assertOk();
$response->assertSee('Settings'); $response->assertSee('Settings');

View File

@ -48,9 +48,12 @@
]); ]);
$user = User::factory()->create(); $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]).'?tab=settings'); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant).'?tab=settings');
$response->assertOk(); $response->assertOk();
$response->assertSee('Settings'); $response->assertSee('Settings');

View File

@ -58,9 +58,12 @@
]); ]);
$user = User::factory()->create(); $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])); ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
$response->assertOk(); $response->assertOk();
$response->assertSee('Normalized settings'); $response->assertSee('Normalized settings');

View File

@ -10,6 +10,7 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Graph\GroupResolver; use App\Services\Graph\GroupResolver;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
use Mockery\MockInterface; use Mockery\MockInterface;
@ -49,11 +50,15 @@
]); ]);
$user = User::factory()->create(['email' => 'tester@example.com']); $user = User::factory()->create(['email' => 'tester@example.com']);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user); $this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ListPolicyVersions::class) Livewire::test(ListPolicyVersions::class)
->callTableAction('restore_via_wizard', $version) ->callTableAction('restore_via_wizard', $version)
->assertRedirectContains(RestoreRunResource::getUrl('create', [], false)); ->assertRedirectContains(RestoreRunResource::getUrl('create', [], false, tenant: $tenant));
$backupSet = BackupSet::query()->where('metadata->source', 'policy_version')->first(); $backupSet = BackupSet::query()->where('metadata->source', 'policy_version')->first();
expect($backupSet)->not->toBeNull(); expect($backupSet)->not->toBeNull();
@ -141,7 +146,11 @@
}); });
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user); $this->actingAs($user);
Filament::setTenant($tenant, true);
$component = Livewire::withQueryParams([ $component = Livewire::withQueryParams([
'backup_set_id' => $backupSet->id, 'backup_set_id' => $backupSet->id,

View File

@ -47,9 +47,12 @@
]); ]);
$user = User::factory()->create(); $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])); ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
$response->assertOk(); $response->assertOk();
$response->assertSee('Scope Tags'); $response->assertSee('Scope Tags');

View File

@ -45,9 +45,12 @@
]); ]);
$user = User::factory()->create(); $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])); ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
$response->assertOk(); $response->assertOk();
$response->assertSee('Raw JSON'); $response->assertSee('Raw JSON');
@ -132,9 +135,12 @@
]); ]);
$user = User::factory()->create(); $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]).'?tab=normalized-settings'); ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant).'?tab=normalized-settings');
$response->assertOk(); $response->assertOk();
$response->assertSee('Enrollment notifications'); $response->assertSee('Enrollment notifications');

View File

@ -31,9 +31,12 @@
$service->captureVersion($policy, ['value' => 2], 'tester'); $service->captureVersion($policy, ['value' => 2], 'tester');
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user) $this->actingAs($user)
->get(route('filament.admin.resources.policy-versions.index')) ->get(route('filament.admin.resources.policy-versions.index', filamentTenantRouteParams($tenant)))
->assertOk() ->assertOk()
->assertSee('Policy A') ->assertSee('Policy A')
->assertSee((string) PolicyVersion::max('version_number')); ->assertSee((string) PolicyVersion::max('version_number'));

View File

@ -71,9 +71,12 @@
]); ]);
$user = User::factory()->create(); $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])); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
$response->assertOk(); $response->assertOk();
$response->assertSee('Settings'); // Settings tab should appear for Settings Catalog $response->assertSee('Settings'); // Settings tab should appear for Settings Catalog
@ -130,9 +133,12 @@
]); ]);
$user = User::factory()->create(); $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])); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
$response->assertOk(); $response->assertOk();
// TODO: Manual verification - check UI for display name "Allow Real-time Monitoring" // TODO: Manual verification - check UI for display name "Allow Real-time Monitoring"
@ -181,9 +187,12 @@
]); ]);
$user = User::factory()->create(); $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])); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
$response->assertOk(); $response->assertOk();
// TODO: Manual verification - check UI shows prettified fallback label // TODO: Manual verification - check UI shows prettified fallback label
@ -225,9 +234,12 @@
]); ]);
$user = User::factory()->create(); $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])); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
$response->assertOk(); $response->assertOk();
$response->assertSee('General'); $response->assertSee('General');
@ -281,8 +293,11 @@
]); ]);
$user = User::factory()->create(); $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])); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
$response->assertOk(); $response->assertOk();
// Policy view should render successfully with Settings Catalog data // Policy view should render successfully with Settings Catalog data
@ -356,8 +371,11 @@
]); ]);
$user = User::factory()->create(); $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])); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
$response->assertOk(); $response->assertOk();
// Value formatting verified by manual UI inspection // Value formatting verified by manual UI inspection
@ -419,8 +437,11 @@
]); ]);
$user = User::factory()->create(); $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])); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
$response->assertOk(); $response->assertOk();
// Search functionality is Alpine.js client-side, requires browser testing // Search functionality is Alpine.js client-side, requires browser testing
@ -465,8 +486,11 @@
]); ]);
$user = User::factory()->create(); $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])); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
$response->assertOk(); $response->assertOk();
// Page renders without crash - actual fallback display requires UI verification // Page renders without crash - actual fallback display requires UI verification

View File

@ -7,6 +7,7 @@
use App\Models\Policy; use App\Models\Policy;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
@ -99,6 +100,10 @@
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(CreateRestoreRun::class) Livewire::test(CreateRestoreRun::class)
->fillForm([ ->fillForm([

View File

@ -12,9 +12,12 @@
$originalEnv = getenv('INTUNE_TENANT_ID'); $originalEnv = getenv('INTUNE_TENANT_ID');
putenv('INTUNE_TENANT_ID='); putenv('INTUNE_TENANT_ID=');
$this->actingAs(User::factory()->create());
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
@ -51,10 +54,10 @@
], ],
]); ]);
$this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('index')) $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('index', tenant: $tenant))
->assertSuccessful(); ->assertSuccessful();
$this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $version]).'?tab=normalized-settings') $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant).'?tab=normalized-settings')
->assertSuccessful(); ->assertSuccessful();
$originalEnv !== false $originalEnv !== false
@ -71,14 +74,17 @@
$originalEnv = getenv('INTUNE_TENANT_ID'); $originalEnv = getenv('INTUNE_TENANT_ID');
putenv('INTUNE_TENANT_ID='); putenv('INTUNE_TENANT_ID=');
$this->actingAs(User::factory()->create());
config([ config([
'tenantpilot.display.show_script_content' => true, 'tenantpilot.display.show_script_content' => true,
'tenantpilot.display.max_script_content_chars' => 5000, 'tenantpilot.display.max_script_content_chars' => 5000,
]); ]);
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
@ -117,7 +123,7 @@
], ],
]); ]);
$url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2]); $url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2], tenant: $tenant);
$this->get($url.'?tab=diff') $this->get($url.'?tab=diff')
->assertSuccessful() ->assertSuccessful()
@ -136,14 +142,17 @@
$originalEnv = getenv('INTUNE_TENANT_ID'); $originalEnv = getenv('INTUNE_TENANT_ID');
putenv('INTUNE_TENANT_ID='); putenv('INTUNE_TENANT_ID=');
$this->actingAs(User::factory()->create());
config([ config([
'tenantpilot.display.show_script_content' => true, 'tenantpilot.display.show_script_content' => true,
'tenantpilot.display.max_script_content_chars' => 5000, 'tenantpilot.display.max_script_content_chars' => 5000,
]); ]);
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
@ -182,7 +191,7 @@
], ],
]); ]);
$url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2]); $url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2], tenant: $tenant);
$this->get($url.'?tab=diff') $this->get($url.'?tab=diff')
->assertSuccessful() ->assertSuccessful()

View File

@ -105,10 +105,13 @@ public function request(string $method, string $path, array $options = []): Grap
); );
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this $response = $this
->actingAs($user) ->actingAs($user)
->get(route('filament.admin.resources.policies.view', ['record' => $policy])); ->get(route('filament.admin.resources.policies.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $policy])));
$response->assertOk(); $response->assertOk();
$response->assertSee('Setting A'); $response->assertSee('Setting A');
@ -145,10 +148,13 @@ public function request(string $method, string $path, array $options = []): Grap
$versions->captureFromGraph($tenant, $policy, createdBy: 'tester@example.com'); $versions->captureFromGraph($tenant, $policy, createdBy: 'tester@example.com');
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this $response = $this
->actingAs($user) ->actingAs($user)
->get(route('filament.admin.resources.policies.view', ['record' => $policy])); ->get(route('filament.admin.resources.policies.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $policy])));
$response->assertOk(); $response->assertOk();
$response->assertSee('Setting A'); $response->assertSee('Setting A');

View File

@ -90,9 +90,12 @@
]); ]);
$user = User::factory()->create(); $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])); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
$policyResponse->assertOk(); $policyResponse->assertOk();
$policyResponse->assertSee('Definition'); $policyResponse->assertSee('Definition');
@ -104,7 +107,7 @@
$policyResponse->assertSee('tp-policy-general-card'); $policyResponse->assertSee('tp-policy-general-card');
$versionResponse = $this->actingAs($user) $versionResponse = $this->actingAs($user)
->get(PolicyVersionResource::getUrl('view', ['record' => $version])); ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
$versionResponse->assertOk(); $versionResponse->assertOk();
$versionResponse->assertSee('Normalized settings'); $versionResponse->assertSee('Normalized settings');

View File

@ -111,10 +111,13 @@ public function request(string $method, string $path, array $options = []): Grap
]); ]);
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this $response = $this
->actingAs($user) ->actingAs($user)
->get(route('filament.admin.resources.policies.index')); ->get(route('filament.admin.resources.policies.index', filamentTenantRouteParams($tenant)));
$response->assertOk(); $response->assertOk();
$response->assertSee('Settings Catalog Policy'); $response->assertSee('Settings Catalog Policy');

View File

@ -147,6 +147,9 @@ public function request(string $method, string $path, array $options = []): Grap
]); ]);
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user); $this->actingAs($user);
$service = app(RestoreService::class); $service = app(RestoreService::class);
@ -185,7 +188,7 @@ public function request(string $method, string $path, array $options = []): Grap
$run->update(['results' => $results]); $run->update(['results' => $results]);
$response = $this->get(route('filament.admin.resources.restore-runs.view', ['record' => $run])); $response = $this->get(route('filament.admin.resources.restore-runs.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $run])));
$response->assertOk(); $response->assertOk();
$response->assertSee('Graph bulk apply failed'); $response->assertSee('Graph bulk apply failed');
$response->assertSee('Setting missing'); $response->assertSee('Setting missing');

View File

@ -162,6 +162,9 @@ public function request(string $method, string $path, array $options = []): Grap
]); ]);
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user); $this->actingAs($user);
$service = app(RestoreService::class); $service = app(RestoreService::class);
@ -201,7 +204,7 @@ public function request(string $method, string $path, array $options = []): Grap
->toBe('#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance'); ->toBe('#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance');
$response = $this $response = $this
->get(route('filament.admin.resources.restore-runs.view', ['record' => $run])); ->get(route('filament.admin.resources.restore-runs.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $run])));
$response->assertOk(); $response->assertOk();
$response->assertSee('settings are read-only'); $response->assertSee('settings are read-only');

View File

@ -56,9 +56,12 @@
]); ]);
$user = User::factory()->create(); $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]).'?tab=settings'); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant).'?tab=settings');
$policyResponse->assertOk(); $policyResponse->assertOk();
$policyResponse->assertSee('fi-width-full'); $policyResponse->assertSee('fi-width-full');
@ -69,7 +72,7 @@
$policyResponse->assertSee('fi-ta-table'); $policyResponse->assertSee('fi-ta-table');
$versionResponse = $this->actingAs($user) $versionResponse = $this->actingAs($user)
->get(PolicyVersionResource::getUrl('view', ['record' => $version])); ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
$versionResponse->assertOk(); $versionResponse->assertOk();
$versionResponse->assertSee('fi-width-full'); $versionResponse->assertSee('fi-width-full');

View File

@ -3,6 +3,7 @@
use App\Filament\Resources\TenantResource\Pages\ListTenants; use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
@ -26,6 +27,11 @@
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$first->getKey() => ['role' => 'owner'],
$second->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($first, true);
Livewire::test(ListTenants::class) Livewire::test(ListTenants::class)
->callTableAction('makeCurrent', $second); ->callTableAction('makeCurrent', $second);

View File

@ -0,0 +1,104 @@
<?php
use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Jobs\BulkTenantSyncJob;
use App\Models\Tenant;
use App\Models\User;
use Filament\Events\TenantSet;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('tenant-scoped pages return 404 for unauthorized tenant', function () {
[$user, $authorizedTenant] = createUserWithTenant();
$unauthorizedTenant = Tenant::factory()->create();
$this->actingAs($user)
->get(route('filament.admin.resources.policies.index', filamentTenantRouteParams($unauthorizedTenant)))
->assertNotFound();
});
test('tenant portfolio lists only tenants the user can access', function () {
$user = User::factory()->create();
$this->actingAs($user);
$authorizedTenant = Tenant::factory()->create([
'tenant_id' => 'tenant-portfolio-authorized',
'name' => 'Authorized Tenant',
]);
$unauthorizedTenant = Tenant::factory()->create([
'tenant_id' => 'tenant-portfolio-unauthorized',
'name' => 'Unauthorized Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$authorizedTenant->getKey() => ['role' => 'owner'],
]);
$this->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($authorizedTenant)))
->assertOk()
->assertSee($authorizedTenant->name)
->assertDontSee($unauthorizedTenant->name);
});
test('tenant portfolio bulk sync dispatches one job per eligible tenant', function () {
Bus::fake();
$user = User::factory()->create();
$this->actingAs($user);
$tenantA = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-a']);
$tenantB = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-b']);
$user->tenants()->syncWithoutDetaching([
$tenantA->getKey() => ['role' => 'owner'],
$tenantB->getKey() => ['role' => 'operator'],
]);
Filament::setTenant($tenantA, true);
Livewire::test(ListTenants::class)
->assertTableBulkActionVisible('syncSelected')
->callTableBulkAction('syncSelected', collect([$tenantA, $tenantB]));
Bus::assertDispatchedTimes(BulkTenantSyncJob::class, 1);
$this->assertDatabaseHas('bulk_operation_runs', [
'tenant_id' => $tenantA->id,
'user_id' => $user->id,
'resource' => 'tenant',
'action' => 'sync',
'total_items' => 2,
]);
});
test('tenant portfolio bulk sync is hidden for readonly users', function () {
$user = User::factory()->create();
$this->actingAs($user);
$tenant = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-readonly']);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'readonly'],
]);
Filament::setTenant($tenant, true);
Livewire::test(ListTenants::class)
->assertTableBulkActionHidden('syncSelected');
});
test('tenant set event updates user tenant preference last used timestamp', function () {
[$user, $tenant] = createUserWithTenant();
TenantSet::dispatch($tenant, $user);
$this->assertDatabaseHas('user_tenant_preferences', [
'user_id' => $user->id,
'tenant_id' => $tenant->id,
]);
});

View File

@ -6,6 +6,7 @@
use App\Models\User; 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 Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Livewire\Livewire; use Livewire\Livewire;
@ -32,6 +33,10 @@ function tenantWithApp(): Tenant
$tenant = tenantWithApp(); $tenant = tenantWithApp();
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->mountAction('setup_rbac') ->mountAction('setup_rbac')
@ -51,6 +56,10 @@ function tenantWithApp(): Tenant
$tenant = tenantWithApp(); $tenant = tenantWithApp();
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null); $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5)); Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5));
@ -155,6 +164,10 @@ public function request(string $method, string $path, array $options = []): Grap
$tenant = tenantWithApp(); $tenant = tenantWithApp();
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null); $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5)); Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5));
@ -265,6 +278,10 @@ public function request(string $method, string $path, array $options = []): Grap
$tenant = tenantWithApp(); $tenant = tenantWithApp();
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null); $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5)); Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5));
@ -365,6 +382,10 @@ public function request(string $method, string $path, array $options = []): Grap
$tenant = tenantWithApp(); $tenant = tenantWithApp();
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->mountAction('setup_rbac') ->mountAction('setup_rbac')
@ -380,6 +401,10 @@ public function request(string $method, string $path, array $options = []): Grap
$tenant = tenantWithApp(); $tenant = tenantWithApp();
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->mountAction('setup_rbac') ->mountAction('setup_rbac')
@ -394,6 +419,10 @@ public function request(string $method, string $path, array $options = []): Grap
$tenant = tenantWithApp(); $tenant = tenantWithApp();
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null); $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5)); Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5));
@ -505,6 +534,10 @@ public function request(string $method, string $path, array $options = []): Grap
$tenant = tenantWithApp(); $tenant = tenantWithApp();
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null); $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5)); Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5));

View File

@ -7,6 +7,7 @@
use App\Models\User; 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 Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
@ -54,9 +55,19 @@ public function request(string $method, string $path, array $options = []): Grap
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$contextTenant = Tenant::create([
'tenant_id' => 'tenant-context',
'name' => 'Context Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$contextTenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($contextTenant, true);
Livewire::test(CreateTenant::class) Livewire::test(CreateTenant::class)
->fillForm([ ->fillForm([
'name' => 'Contoso', 'name' => 'Contoso',
'environment' => 'other',
'tenant_id' => 'tenant-guid', 'tenant_id' => 'tenant-guid',
'domain' => 'contoso.com', 'domain' => 'contoso.com',
'app_client_id' => 'client-123', 'app_client_id' => 'client-123',
@ -65,7 +76,7 @@ public function request(string $method, string $path, array $options = []): Grap
->call('create') ->call('create')
->assertHasNoFormErrors(); ->assertHasNoFormErrors();
$tenant = Tenant::first(); $tenant = Tenant::query()->where('tenant_id', 'tenant-guid')->first();
expect($tenant)->not->toBeNull(); expect($tenant)->not->toBeNull();
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
@ -129,6 +140,11 @@ public function request(string $method, string $path, array $options = []): Grap
'tenant_id' => 'tenant-error', 'tenant_id' => 'tenant-error',
'name' => 'Error Tenant', 'name' => 'Error Tenant',
]); ]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->callAction('verify'); ->callAction('verify');
@ -157,6 +173,9 @@ 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([
$tenant->getKey() => ['role' => 'owner'],
]);
config(['intune_permissions.granted_stub' => []]); config(['intune_permissions.granted_stub' => []]);
@ -169,7 +188,7 @@ public function request(string $method, string $path, array $options = []): Grap
'status' => 'ok', 'status' => 'ok',
]); ]);
$response = $this->get(route('filament.admin.resources.tenants.view', $tenant)); $response = $this->get(route('filament.admin.resources.tenants.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $tenant])));
$response->assertOk(); $response->assertOk();
$response->assertSee('Actions'); $response->assertSee('Actions');
@ -182,13 +201,17 @@ public function request(string $method, string $path, array $options = []): Grap
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
Tenant::create([ $tenant = Tenant::create([
'tenant_id' => 'tenant-ui-list', 'tenant_id' => 'tenant-ui-list',
'name' => 'UI Tenant List', 'name' => 'UI Tenant List',
'app_client_id' => 'client-123', 'app_client_id' => 'client-123',
]); ]);
$response = $this->get(route('filament.admin.resources.tenants.index')); $user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant)));
$response->assertOk(); $response->assertOk();
$response->assertSee('Open in Entra'); $response->assertSee('Open in Entra');
@ -202,6 +225,11 @@ 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'],
]);
Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->callAction('archive'); ->callAction('archive');

View File

@ -48,9 +48,12 @@
]); ]);
$user = User::factory()->create(); $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])); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
$response->assertOk(); $response->assertOk();

View File

@ -10,11 +10,13 @@
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']); unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
$this->tenant = Tenant::factory()->create(); $this->tenant = Tenant::factory()->create();
$this->tenant->makeCurrent();
$this->policy = Policy::factory()->create([ $this->policy = Policy::factory()->create([
'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->tenant->getKey() => ['role' => 'owner'],
]);
}); });
it('displays policy version page', function () { it('displays policy version page', function () {
@ -26,7 +28,10 @@
$this->actingAs($this->user); $this->actingAs($this->user);
$response = $this->get("/admin/policy-versions/{$version->id}"); $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge(
filamentTenantRouteParams($this->tenant),
['record' => $version],
)));
$response->assertOk(); $response->assertOk();
}); });
@ -67,7 +72,10 @@
$this->actingAs($this->user); $this->actingAs($this->user);
$response = $this->get("/admin/policy-versions/{$version->id}"); $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge(
filamentTenantRouteParams($this->tenant),
['record' => $version],
)));
$response->assertOk(); $response->assertOk();
$response->assertSeeLivewire('policy-version-assignments-widget'); $response->assertSeeLivewire('policy-version-assignments-widget');
@ -87,7 +95,10 @@
$this->actingAs($this->user); $this->actingAs($this->user);
$response = $this->get("/admin/policy-versions/{$version->id}"); $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge(
filamentTenantRouteParams($this->tenant),
['record' => $version],
)));
$response->assertOk(); $response->assertOk();
$response->assertSee('Assignments were not captured for this version'); $response->assertSee('Assignments were not captured for this version');
@ -107,7 +118,10 @@
$this->actingAs($this->user); $this->actingAs($this->user);
$response = $this->get("/admin/policy-versions/{$version->id}"); $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge(
filamentTenantRouteParams($this->tenant),
['record' => $version],
)));
$response->assertOk(); $response->assertOk();
$response->assertSee('No assignments found for this version'); $response->assertSee('No assignments found for this version');
@ -137,7 +151,10 @@
$this->actingAs($this->user); $this->actingAs($this->user);
$response = $this->get("/admin/policy-versions/{$version->id}"); $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge(
filamentTenantRouteParams($this->tenant),
['record' => $version],
)));
$response->assertOk(); $response->assertOk();
$response->assertSee('Compliance notifications'); $response->assertSee('Compliance notifications');
@ -169,7 +186,10 @@
$this->actingAs($this->user); $this->actingAs($this->user);
$response = $this->get("/admin/policy-versions/{$version->id}"); $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge(
filamentTenantRouteParams($this->tenant),
['record' => $version],
)));
$response->assertOk(); $response->assertOk();
$response->assertSee('Compliance notifications'); $response->assertSee('Compliance notifications');
@ -192,7 +212,10 @@
$this->actingAs($this->user); $this->actingAs($this->user);
$response = $this->get("/admin/policy-versions/{$version->id}?tab=normalized-settings"); $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge(
filamentTenantRouteParams($this->tenant),
['record' => $version],
)).'?tab=normalized-settings');
$response->assertOk(); $response->assertOk();
$response->assertSee('Password & Access'); $response->assertSee('Password & Access');

View File

@ -8,6 +8,7 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Graph\GroupResolver; use App\Services\Graph\GroupResolver;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
use Mockery\MockInterface; use Mockery\MockInterface;
@ -76,6 +77,11 @@
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$component = Livewire::test(CreateRestoreRun::class) $component = Livewire::test(CreateRestoreRun::class)
->fillForm([ ->fillForm([
@ -157,6 +163,11 @@
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(CreateRestoreRun::class) Livewire::test(CreateRestoreRun::class)
->fillForm([ ->fillForm([

View File

@ -8,6 +8,7 @@
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
@ -86,6 +87,11 @@
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$component = Livewire::test(CreateRestoreRun::class) $component = Livewire::test(CreateRestoreRun::class)
->fillForm([ ->fillForm([

View File

@ -8,6 +8,7 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Graph\GroupResolver; use App\Services\Graph\GroupResolver;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
use Mockery\MockInterface; use Mockery\MockInterface;
@ -77,6 +78,11 @@
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$component = Livewire::test(CreateRestoreRun::class) $component = Livewire::test(CreateRestoreRun::class)
->fillForm([ ->fillForm([
@ -188,6 +194,11 @@
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$component = Livewire::test(CreateRestoreRun::class) $component = Livewire::test(CreateRestoreRun::class)
->fillForm([ ->fillForm([
@ -270,6 +281,11 @@
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$component = Livewire::test(CreateRestoreRun::class) $component = Livewire::test(CreateRestoreRun::class)
->fillForm([ ->fillForm([

View File

@ -5,6 +5,7 @@
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
@ -28,6 +29,11 @@
]); ]);
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::actingAs($user) Livewire::actingAs($user)
->test(ListRestoreRuns::class) ->test(ListRestoreRuns::class)

View File

@ -6,6 +6,7 @@
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
@ -13,7 +14,6 @@
test('rerun action creates a new restore run with the same selections', function () { test('rerun action creates a new restore run with the same selections', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$backupSet = BackupSet::factory()->for($tenant)->create([ $backupSet = BackupSet::factory()->for($tenant)->create([
'status' => 'completed', 'status' => 'completed',
@ -47,6 +47,11 @@
]); ]);
$user = User::factory()->create(['email' => 'tester@example.com']); $user = User::factory()->create(['email' => 'tester@example.com']);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::actingAs($user) Livewire::actingAs($user)
->test(ListRestoreRuns::class) ->test(ListRestoreRuns::class)

View File

@ -9,6 +9,7 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Support\RestoreRunStatus; use App\Support\RestoreRunStatus;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Bus;
use Livewire\Livewire; use Livewire\Livewire;
@ -62,6 +63,11 @@
'name' => 'Tester', 'name' => 'Tester',
]); ]);
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(CreateRestoreRun::class) Livewire::test(CreateRestoreRun::class)
->fillForm([ ->fillForm([
@ -130,6 +136,11 @@
'name' => 'Executor', 'name' => 'Executor',
]); ]);
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(CreateRestoreRun::class) Livewire::test(CreateRestoreRun::class)
->fillForm([ ->fillForm([

View File

@ -6,6 +6,7 @@
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
@ -50,6 +51,11 @@
'name' => 'Tester', 'name' => 'Tester',
]); ]);
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(CreateRestoreRun::class) Livewire::test(CreateRestoreRun::class)
->fillForm([ ->fillForm([

View File

@ -1,5 +1,7 @@
<?php <?php
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
/* /*
@ -60,3 +62,26 @@ function something()
{ {
// .. // ..
} }
/**
* @return array{0: User, 1: Tenant}
*/
function createUserWithTenant(?Tenant $tenant = null, ?User $user = null, string $role = 'owner'): array
{
$user ??= User::factory()->create();
$tenant ??= Tenant::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => $role],
]);
return [$user, $tenant];
}
/**
* @return array{tenant: string}
*/
function filamentTenantRouteParams(Tenant $tenant): array
{
return ['tenant' => (string) $tenant->external_id];
}

View File

@ -4,6 +4,7 @@
use App\Models\Policy; use App\Models\Policy;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
use Tests\TestCase; use Tests\TestCase;
@ -12,8 +13,12 @@
test('policies bulk actions are available for authenticated users', function () { test('policies bulk actions are available for authenticated users', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$policies = Policy::factory()->count(2)->create(['tenant_id' => $tenant->id]); $policies = Policy::factory()->count(2)->create(['tenant_id' => $tenant->id]);
Livewire::actingAs($user) Livewire::actingAs($user)