merge: agent session work

This commit is contained in:
Ahmed Darrazi 2026-01-04 20:59:52 +01:00
commit 8d90550abe
70 changed files with 1276 additions and 128 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\ViewAction::make()
->label('View policy')
->url(fn ($record) => $record->policy_id ? PolicyResource::getUrl('view', ['record' => $record->policy_id]) : null)
->hidden(fn ($record) => ! $record->policy_id)
->url(function (BackupItem $record): ?string {
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),
Actions\Action::make('remove')
->label('Remove')

View File

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

View File

@ -4,13 +4,16 @@
use App\Filament\Resources\TenantResource\Pages;
use App\Http\Controllers\RbacDelegatedAuthController;
use App\Jobs\SyncPoliciesJob;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RbacHealthService;
use App\Services\Intune\RbacOnboardingService;
use App\Services\Intune\TenantConfigService;
use App\Services\Intune\TenantPermissionService;
use App\Support\TenantRole;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\ActionGroup;
@ -23,6 +26,8 @@
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
@ -33,6 +38,8 @@ class TenantResource extends Resource
{
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|UnitEnum|null $navigationGroup = 'Settings';
@ -44,6 +51,15 @@ public static function form(Schema $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()
@ -69,10 +85,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
{
return $table
->modifyQueryUsing(fn (\Illuminate\Database\Eloquent\Builder $query) => $query->withTrashed())
->columns([
Tables\Columns\TextColumn::make('name')
->searchable(),
@ -80,6 +114,23 @@ public static function table(Table $table): Table
->label('Tenant ID')
->copyable()
->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')
->copyable()
->toggleable(),
@ -102,6 +153,13 @@ public static function table(Table $table): Table
->trueLabel('All')
->falseLabel('Archived')
->default(true),
Tables\Filters\SelectFilter::make('environment')
->options([
'prod' => 'PROD',
'dev' => 'DEV',
'staging' => 'STAGING',
'other' => 'Other',
]),
Tables\Filters\SelectFilter::make('app_status')
->options([
'ok' => 'OK',
@ -113,6 +171,50 @@ public static function table(Table $table): Table
->actions([
Actions\ViewAction::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()
->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\RestoreAction::make()
->label('Restore')
@ -242,7 +344,78 @@ public static function table(Table $table): Table
}),
])->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));
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("Dispatched sync for {$count} tenant(s).")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->success()
->send();
})
->deselectRecordsAfterCompletion(),
])
->headerActions([]);
}
@ -440,7 +613,10 @@ public static function rbacAction(): Actions\Action
->label('Open RBAC login')
->url(route('admin.rbac.start', [
'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()
@ -579,7 +755,10 @@ private static function loginToSearchRolesAction(?Tenant $tenant): ?Actions\Acti
->label('Login to load roles')
->url(route('admin.rbac.start', [
'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 +940,10 @@ private static function loginToSearchGroupsAction(?Tenant $tenant): ?Actions\Act
->label('Login to search groups')
->url(route('admin.rbac.start', [
'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;
use App\Filament\Resources\TenantResource;
use App\Models\User;
use App\Support\TenantRole;
use Filament\Resources\Pages\CreateRecord;
class CreateTenant extends CreateRecord
{
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

@ -2,16 +2,19 @@
namespace App\Models;
use Filament\Facades\Filament;
use Filament\Models\Contracts\HasName;
use Illuminate\Database\Eloquent\Builder;
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\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use RuntimeException;
class Tenant extends Model
class Tenant extends Model implements HasName
{
use HasFactory;
use SoftDeletes;
@ -114,6 +117,12 @@ public function makeCurrent(): void
public static function current(): self
{
$filamentTenant = Filament::getTenant();
if ($filamentTenant instanceof self) {
return $filamentTenant;
}
$envTenantId = getenv('INTUNE_TENANT_ID') ?: null;
if ($envTenantId) {
@ -142,6 +151,20 @@ public static function current(): self
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
{
return $this->hasMany(Policy::class);

View File

@ -2,13 +2,21 @@
namespace App\Models;
use App\Support\TenantRole;
use Filament\Models\Contracts\FilamentUser;
use Filament\Models\Contracts\HasDefaultTenant;
use Filament\Models\Contracts\HasTenants;
use Filament\Panel;
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\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, Notifiable;
@ -51,4 +59,113 @@ public function canAccessPanel(Panel $panel): bool
{
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;
use App\Models\Tenant;
use App\Models\User;
use App\Models\UserTenantPreference;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\MicrosoftGraphClient;
use App\Services\Graph\NullGraphClient;
@ -18,6 +21,9 @@
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
use App\Services\Intune\WindowsUpdateRingNormalizer;
use Filament\Events\TenantSet;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@ -66,6 +72,35 @@ public function register(): 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;
use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Models\Tenant;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
@ -29,6 +31,10 @@ public function panel(Panel $panel): Panel
->id('admin')
->path('admin')
->login()
->tenant(Tenant::class, slugAttribute: 'external_id')
->tenantRoutePrefix('t')
->searchableTenantMenu()
->tenantRegistration(RegisterTenant::class)
->colors([
'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_notes' => null,
'status' => 'active',
'environment' => 'other',
'is_current' => false,
'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>
@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>
</body>
</html>

View File

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

@ -2,7 +2,7 @@ # Feature Specification: Tenant Portfolio & Context Switch (031)
**Feature Branch**: `feat/031-tenant-portfolio-context-switch`
**Created**: 2026-01-04
**Status**: Proposed
**Status**: Implemented (ready to merge)
**Risk**: Medium
**Priority**: P1

View File

@ -8,27 +8,26 @@ ## Phase 1: Setup
- [x] T001 Create spec/plan/tasks and checklist.
## Phase 2: Research & Design
- [ ] T002 Review Filament tenancy support and choose the context mechanism (route vs session).
- [ ] T003 Define tenant access roles and mapping (user memberships; future org/group principals).
- [ ] T004 Decide how to store `environment` (column vs JSONB) and whether MSP “customer grouping” is in scope.
- [ ] T005 Define context precedence rules (env override, route tenant, session/default tenant) and cross-tab safety expectations.
- [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)
- [ ] T006 Authorization: user cannot open unauthorized tenant (403).
- [ ] T007 Authorization: tenant-scoped resources deny cross-tenant access via URL (403/404).
- [ ] T008 Context switching: “Open tenant” sets context and tenant-scoped pages filter correctly.
- [ ] T009 Bulk sync: dispatches one job per selected tenant; readonly role cannot run it.
- [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
- [ ] T011 Add migrations for tenant memberships/roles and environment attribute (and optional preferences).
- [ ] T012 Implement `TenantContext` + authorization gate/policy (`canAccessTenant`).
- [ ] T013 Integrate tenant switcher into Filament topbar and make Current Tenant always visible.
- [ ] T014 Scope tenant resources (Policies/Backups/RestoreRuns/etc.) via TenantContext; replace direct `Tenant::current()` usage.
- [ ] T015 Update `TenantResource` into a portfolio view: access-scoped query, columns, filters, “Open”, “Sync”, bulk “Sync selected”.
- [ ] T016 Add restore guardrails (target tenant header + tenant-aware confirmations).
- [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
- [ ] T017 Run targeted tests.
- [ ] T018 Run Pint (`./vendor/bin/pint --dirty`).
- [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\Tenant;
use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
@ -14,8 +15,12 @@
test('backup sets table bulk archive creates a run and archives selected sets', function () {
$tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$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) {
return BackupSet::create([
@ -58,8 +63,12 @@
test('backup sets can be archived even when referenced by restore runs', function () {
$tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$set = BackupSet::create([
'tenant_id' => $tenant->id,
@ -87,8 +96,12 @@
test('backup sets table bulk archive requires type-to-confirm for 10+ sets', function () {
$tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$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) {
return BackupSet::create([

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
@ -11,8 +12,12 @@
test('bulk delete requires confirmation string for large batches', function () {
$tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$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]);
Livewire::actingAs($user)
@ -27,8 +32,12 @@
test('bulk delete fails with incorrect confirmation string', function () {
$tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$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]);
Livewire::actingAs($user)
@ -43,8 +52,12 @@
test('bulk delete does not require confirmation string for small batches', function () {
$tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$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]);
Livewire::actingAs($user)

View File

@ -47,9 +47,12 @@
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy]));
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
$response->assertOk();
$response->assertSee('Data Protection');

View File

@ -22,6 +22,9 @@
$this->tenant = $tenant;
$this->user = User::factory()->create();
$this->user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
});
test('policy detail renders normalized settings for Autopilot profiles', function () {
@ -54,7 +57,7 @@
]);
$response = $this->actingAs($this->user)
->get(PolicyResource::getUrl('view', ['record' => $policy]));
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $this->tenant));
$response->assertOk();
$response->assertSee('Settings');
@ -95,7 +98,7 @@
]);
$response = $this->actingAs($this->user)
->get(PolicyResource::getUrl('view', ['record' => $policy]));
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $this->tenant));
$response->assertOk();
$response->assertSee('Settings');
@ -139,7 +142,7 @@
]);
$response = $this->actingAs($this->user)
->get(PolicyResource::getUrl('view', ['record' => $policy]));
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $this->tenant));
$response->assertOk();
$response->assertSee('Settings');

View File

@ -133,10 +133,13 @@ public function request(string $method, string $path, array $options = []): Grap
);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this
->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->assertSee('Block legacy auth');

View File

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

View File

@ -41,14 +41,17 @@
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$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');
$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');
});

View File

@ -100,9 +100,12 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$detailResponse = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy]));
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
$detailResponse->assertSee('@odata.type mismatch');

View File

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

View File

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

View File

@ -49,9 +49,12 @@
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy]));
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
$response->assertOk();
$response->assertSee('Settings');

View File

@ -48,9 +48,12 @@
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$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->assertSee('Settings');

View File

@ -58,9 +58,12 @@
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user)
->get(PolicyVersionResource::getUrl('view', ['record' => $version]));
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
$response->assertOk();
$response->assertSee('Normalized settings');

View File

@ -10,6 +10,7 @@
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GroupResolver;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery\MockInterface;
@ -49,11 +50,15 @@
]);
$user = User::factory()->create(['email' => 'tester@example.com']);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ListPolicyVersions::class)
->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();
expect($backupSet)->not->toBeNull();
@ -141,7 +146,11 @@
});
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
$component = Livewire::withQueryParams([
'backup_set_id' => $backupSet->id,

View File

@ -47,9 +47,12 @@
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user)
->get(PolicyVersionResource::getUrl('view', ['record' => $version]));
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
$response->assertOk();
$response->assertSee('Scope Tags');

View File

@ -45,9 +45,12 @@
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user)
->get(PolicyVersionResource::getUrl('view', ['record' => $version]));
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
$response->assertOk();
$response->assertSee('Raw JSON');
@ -132,9 +135,12 @@
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$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->assertSee('Enrollment notifications');

View File

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

View File

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

View File

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

View File

@ -12,9 +12,12 @@
$originalEnv = getenv('INTUNE_TENANT_ID');
putenv('INTUNE_TENANT_ID=');
$this->actingAs(User::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);
$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();
$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();
$originalEnv !== false
@ -71,14 +74,17 @@
$originalEnv = getenv('INTUNE_TENANT_ID');
putenv('INTUNE_TENANT_ID=');
$this->actingAs(User::factory()->create());
config([
'tenantpilot.display.show_script_content' => true,
'tenantpilot.display.max_script_content_chars' => 5000,
]);
$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);
$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')
->assertSuccessful()
@ -136,14 +142,17 @@
$originalEnv = getenv('INTUNE_TENANT_ID');
putenv('INTUNE_TENANT_ID=');
$this->actingAs(User::factory()->create());
config([
'tenantpilot.display.show_script_content' => true,
'tenantpilot.display.max_script_content_chars' => 5000,
]);
$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);
$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')
->assertSuccessful()

View File

@ -105,10 +105,13 @@ public function request(string $method, string $path, array $options = []): Grap
);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this
->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->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');
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this
->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->assertSee('Setting A');

View File

@ -90,9 +90,12 @@
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$policyResponse = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy]));
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
$policyResponse->assertOk();
$policyResponse->assertSee('Definition');
@ -104,7 +107,7 @@
$policyResponse->assertSee('tp-policy-general-card');
$versionResponse = $this->actingAs($user)
->get(PolicyVersionResource::getUrl('view', ['record' => $version]));
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
$versionResponse->assertOk();
$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->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this
->actingAs($user)
->get(route('filament.admin.resources.policies.index'));
->get(route('filament.admin.resources.policies.index', filamentTenantRouteParams($tenant)));
$response->assertOk();
$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->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user);
$service = app(RestoreService::class);
@ -185,7 +188,7 @@ public function request(string $method, string $path, array $options = []): Grap
$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->assertSee('Graph bulk apply failed');
$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->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user);
$service = app(RestoreService::class);
@ -201,7 +204,7 @@ public function request(string $method, string $path, array $options = []): Grap
->toBe('#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance');
$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->assertSee('settings are read-only');

View File

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

View File

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

View File

@ -0,0 +1,99 @@
<?php
use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Jobs\SyncPoliciesJob;
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(SyncPoliciesJob::class, 2);
Bus::assertDispatched(SyncPoliciesJob::class, fn (SyncPoliciesJob $job) => $job->tenantId === $tenantA->id);
Bus::assertDispatched(SyncPoliciesJob::class, fn (SyncPoliciesJob $job) => $job->tenantId === $tenantB->id);
});
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\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Livewire\Livewire;
@ -32,6 +33,10 @@ function tenantWithApp(): Tenant
$tenant = tenantWithApp();
$user = User::factory()->create();
$this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->mountAction('setup_rbac')
@ -51,6 +56,10 @@ function tenantWithApp(): Tenant
$tenant = tenantWithApp();
$user = User::factory()->create();
$this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5));
@ -155,6 +164,10 @@ public function request(string $method, string $path, array $options = []): Grap
$tenant = tenantWithApp();
$user = User::factory()->create();
$this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5));
@ -265,6 +278,10 @@ public function request(string $method, string $path, array $options = []): Grap
$tenant = tenantWithApp();
$user = User::factory()->create();
$this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5));
@ -365,6 +382,10 @@ public function request(string $method, string $path, array $options = []): Grap
$tenant = tenantWithApp();
$user = User::factory()->create();
$this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->mountAction('setup_rbac')
@ -380,6 +401,10 @@ public function request(string $method, string $path, array $options = []): Grap
$tenant = tenantWithApp();
$user = User::factory()->create();
$this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->mountAction('setup_rbac')
@ -394,6 +419,10 @@ public function request(string $method, string $path, array $options = []): Grap
$tenant = tenantWithApp();
$user = User::factory()->create();
$this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5));
@ -505,6 +534,10 @@ public function request(string $method, string $path, array $options = []): Grap
$tenant = tenantWithApp();
$user = User::factory()->create();
$this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5));

View File

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

View File

@ -48,9 +48,12 @@
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy]));
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
$response->assertOk();

View File

@ -10,11 +10,13 @@
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
$this->tenant = Tenant::factory()->create();
$this->tenant->makeCurrent();
$this->policy = Policy::factory()->create([
'tenant_id' => $this->tenant->id,
]);
$this->user = User::factory()->create();
$this->user->tenants()->syncWithoutDetaching([
$this->tenant->getKey() => ['role' => 'owner'],
]);
});
it('displays policy version page', function () {
@ -26,7 +28,10 @@
$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();
});
@ -67,7 +72,10 @@
$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->assertSeeLivewire('policy-version-assignments-widget');
@ -87,7 +95,10 @@
$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->assertSee('Assignments were not captured for this version');
@ -107,7 +118,10 @@
$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->assertSee('No assignments found for this version');
@ -137,7 +151,10 @@
$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->assertSee('Compliance notifications');
@ -169,7 +186,10 @@
$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->assertSee('Compliance notifications');
@ -192,7 +212,10 @@
$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->assertSee('Password & Access');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,7 @@
<?php
use App\Models\Tenant;
use App\Models\User;
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\Tenant;
use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
@ -12,8 +13,12 @@
test('policies bulk actions are available for authenticated users', function () {
$tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$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]);
Livewire::actingAs($user)