1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-02-04 12:04:02 +00:00

Merge master, add more assertions, use array_merge() syntax

This commit is contained in:
Samuel Stancl 2025-12-16 22:56:34 +01:00
commit 12b3b50230
No known key found for this signature in database
GPG key ID: BA146259A1E16C57
45 changed files with 856 additions and 201 deletions

View file

@ -201,6 +201,27 @@ test('tenant storage can get deleted after the tenant when DeletingTenant listen
expect(File::isDirectory($tenantStoragePath))->toBeFalse();
});
test('the framework/cache directory is created when storage_path is scoped', function (bool $suffixStoragePath) {
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
],
'tenancy.filesystem.suffix_storage_path' => $suffixStoragePath
]);
$centralStoragePath = storage_path();
tenancy()->initialize($tenant = Tenant::create());
if ($suffixStoragePath) {
expect(storage_path('framework/cache'))->toBe($centralStoragePath . "/tenant{$tenant->id}/framework/cache");
expect(is_dir($centralStoragePath . "/tenant{$tenant->id}/framework/cache"))->toBeTrue();
} else {
expect(storage_path('framework/cache'))->toBe($centralStoragePath . '/framework/cache');
expect(is_dir($centralStoragePath . "/tenant{$tenant->id}/framework/cache"))->toBeFalse();
}
})->with([true, false]);
test('scoped disks are scoped per tenant', function () {
config([
'tenancy.bootstrappers' => [
@ -215,16 +236,24 @@ test('scoped disks are scoped per tenant', function () {
$tenant = Tenant::create();
$storagePath = storage_path() . "/tenant{$tenant->id}";
// Resolve scoped_disk before initializing tenancy
Storage::disk('scoped_disk');
Storage::disk('scoped_disk')->put('foo.txt', 'central');
expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('central');
expect(file_get_contents(storage_path() . "/app/public/scoped_disk_prefix/foo.txt"))->toBe('central');
tenancy()->initialize($tenant);
Storage::disk('scoped_disk')->put('foo.txt', 'foo text');
expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe(null);
Storage::disk('scoped_disk')->put('foo.txt', 'tenant');
expect(file_get_contents(storage_path() . "/app/public/scoped_disk_prefix/foo.txt"))->toBe('tenant');
expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('tenant');
tenancy()->end();
expect(File::exists($storagePath . '/app/public/scoped_disk_prefix/foo.txt'))->toBeTrue();
expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('central');
Storage::disk('scoped_disk')->put('foo.txt', 'central2');
expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('central2');
expect(file_get_contents(storage_path() . "/app/public/scoped_disk_prefix/foo.txt"))->toBe('central2');
expect(file_get_contents(storage_path() . "/tenant{$tenant->id}/app/public/scoped_disk_prefix/foo.txt"))->toBe('tenant');
});

View file

@ -18,7 +18,6 @@ use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByRequestDataException;
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
@ -80,6 +79,44 @@ test('tenancy url generator can prefix route names passed to the route helper',
expect(route('home'))->toBe('http://localhost/central/home');
});
test('tenancy url generator inherits scheme from original url generator', function() {
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
Route::get('/home', fn () => '')->name('home');
// No scheme forced, default is HTTP
expect(app('url')->formatScheme())->toBe('http://');
$tenant = Tenant::create();
// Force the original URL generator to use HTTPS
app('url')->forceScheme('https');
// Original generator uses HTTPS
expect(app('url')->formatScheme())->toBe('https://');
// Check that TenancyUrlGenerator inherits the HTTPS scheme
tenancy()->initialize($tenant);
expect(app('url')->formatScheme())->toBe('https://'); // Should inherit HTTPS
expect(route('home'))->toBe('https://localhost/home');
tenancy()->end();
// After ending tenancy, the original generator should still have the original scheme (HTTPS)
expect(route('home'))->toBe('https://localhost/home');
// Use HTTP scheme
app('url')->forceScheme('http');
expect(app('url')->formatScheme())->toBe('http://');
tenancy()->initialize($tenant);
expect(app('url')->formatScheme())->toBe('http://'); // Should inherit scheme (HTTP)
expect(route('home'))->toBe('http://localhost/home');
tenancy()->end();
expect(route('home'))->toBe('http://localhost/home');
});
test('path identification route helper behavior', function (bool $addTenantParameterToDefaults, bool $passTenantParameterToRoutes) {
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);

View file

@ -401,3 +401,77 @@ test('tenant parameter addition can be controlled by setting addTenantParameter'
$this->withoutExceptionHandling()->get('http://central.localhost/foo')->assertSee('central');
}
})->with([true, false]);
test('existing context flags are removed during cloning', function () {
RouteFacade::get('/foo', fn () => true)->name('foo')->middleware(['clone', 'central']);
RouteFacade::get('/bar', fn () => true)->name('bar')->middleware(['clone', 'universal']);
$cloneAction = app(CloneRoutesAsTenant::class);
// Clone foo route
$cloneAction->handle();
expect(collect(RouteFacade::getRoutes()->get())->map->getName())
->toContain('tenant.foo');
expect(tenancy()->getRouteMiddleware(RouteFacade::getRoutes()->getByName('tenant.foo')))
->not()->toContain('central');
// Clone bar route
$cloneAction->handle();
expect(collect(RouteFacade::getRoutes()->get())->map->getName())
->toContain('tenant.foo', 'tenant.bar');
expect(tenancy()->getRouteMiddleware(RouteFacade::getRoutes()->getByName('tenant.foo')))
->not()->toContain('universal');
});
test('cloning a route without a prefix or differing domains overrides the original route', function () {
RouteFacade::get('/foo', fn () => true)->name('foo')->middleware(['clone']);
expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('foo');
$cloneAction = CloneRoutesAsTenant::make();
$cloneAction->cloneRoute('foo')
->addTenantParameter(false)
->tenantParameterBeforePrefix(false)
->handle();
expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.foo');
expect(collect(RouteFacade::getRoutes()->get())->map->getName())->not()->toContain('foo');
});
test('addTenantMiddleware can be used to specify the tenant middleware for the cloned route', function () {
RouteFacade::get('/foo', fn () => true)->name('foo')->middleware(['clone']);
RouteFacade::get('/bar', fn () => true)->name('bar')->middleware(['clone']);
$cloneAction = app(CloneRoutesAsTenant::class);
$cloneAction->cloneRoute('foo')->addTenantMiddleware([InitializeTenancyByPath::class])->handle();
expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.foo');
$cloned = RouteFacade::getRoutes()->getByName('tenant.foo');
expect($cloned->uri())->toBe('{tenant}/foo');
expect($cloned->getName())->toBe('tenant.foo');
expect(tenancy()->getRouteMiddleware($cloned))->toBe([InitializeTenancyByPath::class]);
$cloneAction->cloneRoute('bar')
->addTenantMiddleware([InitializeTenancyByDomain::class])
->domain('foo.localhost')
->addTenantParameter(false)
->tenantParameterBeforePrefix(false)
->handle();
expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.bar');
$cloned = RouteFacade::getRoutes()->getByName('tenant.bar');
expect($cloned->uri())->toBe('bar');
expect($cloned->getName())->toBe('tenant.bar');
expect($cloned->getDomain())->toBe('foo.localhost');
expect(tenancy()->getRouteMiddleware($cloned))->toBe([InitializeTenancyByDomain::class]);
});
test('cloneRoutes can be used to clone multiple routes', function () {
RouteFacade::get('/foo', fn () => true)->name('foo');
$bar = RouteFacade::get('/bar', fn () => true)->name('bar');
RouteFacade::get('/baz', fn () => true)->name('baz');
CloneRoutesAsTenant::make()->cloneRoutes(['foo', $bar])->handle();
expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.foo');
expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.bar');
expect(collect(RouteFacade::getRoutes()->get())->map->getName())->not()->toContain('tenant.baz');
});

View file

@ -12,6 +12,7 @@ use Stancl\Tenancy\ResourceSyncing\SyncMaster;
class CentralUser extends Model implements SyncMaster
{
use ResourceSyncing, CentralConnection;
protected $guarded = [];
public $timestamps = false;

View file

@ -4,20 +4,13 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Tests\Etc\ResourceSyncing;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Stancl\Tenancy\ResourceSyncing\PivotWithRelation;
use Stancl\Tenancy\ResourceSyncing\PivotWithCentralResource;
use Stancl\Tenancy\ResourceSyncing\TenantPivot;
class CustomPivot extends TenantPivot implements PivotWithRelation
class CustomPivot extends TenantPivot implements PivotWithCentralResource
{
public function users(): BelongsToMany
public function getCentralResourceClass(): string
{
return $this->belongsToMany(CentralUser::class);
}
public function getRelatedModel(): Model
{
return $this->users()->getModel();
return CentralUser::class;
}
}

View file

@ -16,9 +16,6 @@ class CreateTenantUsersTable extends Migration
$table->string('global_user_id');
$table->unique(['tenant_id', 'global_user_id']);
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
$table->foreign('global_user_id')->references('global_id')->on('users')->onUpdate('cascade')->onDelete('cascade');
});
}

View file

@ -17,6 +17,7 @@ use Stancl\Tenancy\Commands\CreateUserWithRLSPolicies;
use Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager;
use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager;
use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper;
use Stancl\Tenancy\Tenancy;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
@ -189,6 +190,22 @@ test('rls command recreates policies if the force option is passed', function (s
TraitRLSManager::class,
]);
test('dropRLSPolicies only drops RLS policies', function () {
DB::statement('CREATE POLICY "comments_dummy_rls_policy" ON comments USING (true)');
DB::statement('CREATE POLICY "comments_foo_policy" ON comments USING (true)'); // non-RLS policy
$policyCount = fn () => count(DB::select("SELECT policyname FROM pg_policies WHERE tablename = 'comments'"));
expect($policyCount())->toBe(2);
$removed = Tenancy::dropRLSPolicies('comments');
expect($removed)->toBe(1);
// Only the non-RLS policy remains
expect($policyCount())->toBe(1);
});
test('queries will stop working when the tenant session variable is not set', function(string $manager, bool $forceRls) {
CreateUserWithRLSPolicies::$forceRls = $forceRls;

View file

@ -46,6 +46,13 @@ use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase;
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Database\QueryException;
use function Stancl\Tenancy\Tests\pest;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Stancl\Tenancy\Events\TenantDeleted;
use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceDeleted;
use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteAllTenantMappings;
use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteResourceMapping;
use Illuminate\Database\Eloquent\Relations\Relation;
beforeEach(function () {
config(['tenancy.bootstrappers' => [
@ -69,6 +76,7 @@ beforeEach(function () {
CreateTenantResource::$shouldQueue = false;
DeleteResourceInTenant::$shouldQueue = false;
UpdateOrCreateSyncedResource::$scopeGetModelQuery = null;
DeleteAllTenantMappings::$pivotTables = ['tenant_resources' => 'tenant_id'];
// Reset global scopes on models (should happen automatically but to make this more explicit)
Model::clearBootedModels();
@ -92,6 +100,7 @@ beforeEach(function () {
CentralUser::$creationAttributes = $creationAttributes;
Event::listen(SyncedResourceSaved::class, UpdateOrCreateSyncedResource::class);
Event::listen(SyncedResourceDeleted::class, DeleteResourceMapping::class);
Event::listen(SyncMasterDeleted::class, DeleteResourcesInTenants::class);
Event::listen(SyncMasterRestored::class, RestoreResourcesInTenants::class);
Event::listen(CentralResourceAttachedToTenant::class, CreateTenantResource::class);
@ -255,7 +264,7 @@ test('attaching central resources to tenants or vice versa creates synced tenant
expect(TenantUser::all())->toHaveCount(0);
});
// Attaching resources to tenants requires using a pivot that implements the PivotWithRelation interface
// Attaching resources to tenants requires using a pivot that implements the PivotWithCentralResource interface
$tenant->customPivotUsers()->attach($createCentralUser());
$createCentralUser()->tenants()->attach($tenant);
@ -279,7 +288,7 @@ test('detaching central users from tenants or vice versa force deletes the synce
migrateUsersTableForTenants();
if ($attachUserToTenant) {
// Attaching resources to tenants requires using a pivot that implements the PivotWithRelation interface
// Attaching resources to tenants requires using a pivot that implements the PivotWithCentralResource interface
$tenant->customPivotUsers()->attach($centralUser);
} else {
$centralUser->tenants()->attach($tenant);
@ -290,7 +299,7 @@ test('detaching central users from tenants or vice versa force deletes the synce
});
if ($attachUserToTenant) {
// Detaching resources from tenants requires using a pivot that implements the PivotWithRelation interface
// Detaching resources from tenants requires using a pivot that implements the PivotWithCentralResource interface
$tenant->customPivotUsers()->detach($centralUser);
} else {
$centralUser->tenants()->detach($tenant);
@ -325,7 +334,7 @@ test('detaching central users from tenants or vice versa force deletes the synce
});
if ($attachUserToTenant) {
// Detaching resources from tenants requires using a pivot that implements the PivotWithRelation interface
// Detaching resources from tenants requires using a pivot that implements the PivotWithCentralResource interface
$tenant->customPivotUsers()->detach($centralUserWithSoftDeletes);
} else {
$centralUserWithSoftDeletes->tenants()->detach($tenant);
@ -890,7 +899,54 @@ test('deleting SyncMaster automatically deletes its Syncables', function (bool $
'basic pivot' => false,
]);
test('tenant pivot records are deleted along with the tenants to which they belong to', function() {
test('tenant pivot records are deleted along with the tenants to which they belong', function (bool $dbLevelOnCascadeDelete, bool $morphPivot) {
[$tenant] = createTenantsAndRunMigrations();
if ($morphPivot) {
config(['tenancy.models.tenant' => MorphTenant::class]);
$centralUserModel = BaseCentralUser::class;
// The default pivot table, no need to configure the listener
$pivotTable = 'tenant_resources';
} else {
$centralUserModel = CentralUser::class;
// Custom pivot table
$pivotTable = 'tenant_users';
}
if ($dbLevelOnCascadeDelete) {
addTenantIdConstraintToPivot($pivotTable);
} else {
// Event-based cleanup
Event::listen(TenantDeleted::class, DeleteAllTenantMappings::class);
DeleteAllTenantMappings::$pivotTables = [$pivotTable => 'tenant_id'];
}
$syncMaster = $centralUserModel::create([
'global_id' => 'user',
'name' => 'Central user',
'email' => 'central@localhost',
'password' => 'password',
'role' => 'user',
]);
$syncMaster->tenants()->attach($tenant);
// Pivot records should be deleted along with the tenant
$tenant->delete();
expect(DB::select("SELECT * FROM {$pivotTable} WHERE tenant_id = ?", [$tenant->getTenantKey()]))->toHaveCount(0);
})->with([
'db level on cascade delete' => true,
'event-based on cascade delete' => false,
])->with([
'polymorphic pivot' => true,
'basic pivot' => false,
]);
test('pivot record is automatically deleted with the tenant resource', function() {
[$tenant] = createTenantsAndRunMigrations();
$syncMaster = CentralUser::create([
@ -903,10 +959,54 @@ test('tenant pivot records are deleted along with the tenants to which they belo
$syncMaster->tenants()->attach($tenant);
$tenant->delete();
expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(1);
// Deleting tenant deletes its pivot records
expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->getTenantKey()]))->toHaveCount(0);
$tenant->run(function () {
TenantUser::firstWhere('global_id', 'cascade_user')->delete();
});
// Deleting tenant resource deletes its pivot record
expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(0);
// The same works with forceDelete
addExtraColumns(true);
$syncMaster = CentralUserWithSoftDeletes::create([
'global_id' => 'force_cascade_user',
'name' => 'Central user',
'email' => 'central2@localhost',
'password' => 'password',
'role' => 'force_cascade_user',
'foo' => 'bar',
]);
$syncMaster->tenants()->attach($tenant);
expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(1);
$tenant->run(function () {
TenantUserWithSoftDeletes::firstWhere('global_id', 'force_cascade_user')->forceDelete();
});
expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(0);
});
test('DeleteAllTenantMappings handles incorrect configuration correctly', function() {
Event::listen(TenantDeleted::class, DeleteAllTenantMappings::class);
[$tenant1, $tenant2] = createTenantsAndRunMigrations();
// Existing table, non-existent tenant key column
// The listener should throw an 'unknown column' exception
DeleteAllTenantMappings::$pivotTables = ['tenant_users' => 'non_existent_column'];
// Should throw an exception when tenant is deleted
expect(fn() => $tenant1->delete())->toThrow(QueryException::class, "Unknown column 'non_existent_column' in 'where clause'");
// Non-existent table
DeleteAllTenantMappings::$pivotTables = ['nonexistent_pivot' => 'non_existent_column'];
expect(fn() => $tenant2->delete())->toThrow(QueryException::class, "Table 'main.nonexistent_pivot' doesn't exist");
});
test('trashed resources are synced correctly', function () {
@ -1265,6 +1365,60 @@ test('global scopes on syncable models can break resource syncing', function ()
expect($tenant1->run(fn () => TenantUser::first()->name))->toBe('tenant2 user');
});
test('attach and detach events are handled correctly when using morph maps', function() {
config(['tenancy.models.tenant' => MorphTenant::class]);
[$tenant] = createTenantsAndRunMigrations();
migrateCompaniesTableForTenants();
Relation::morphMap([
'users' => BaseCentralUser::class,
'companies' => CentralCompany::class,
]);
$centralUser = BaseCentralUser::create([
'global_id' => 'user',
'name' => 'Central user',
'email' => 'central@localhost',
'password' => 'password',
'role' => 'user',
]);
$centralCompany = CentralCompany::create([
'global_id' => 'company',
'name' => 'Central company',
'email' => 'company@localhost',
]);
$tenant->users()->attach($centralUser);
$tenant->companies()->attach($centralCompany);
// Assert all tenant_resources mappings actually use the configured morph map
expect(DB::table('tenant_resources')->count())
->toBe(DB::table('tenant_resources')->whereIn('tenant_resources_type', ['users', 'companies'])->count());
tenancy()->initialize($tenant);
expect(BaseTenantUser::whereGlobalId('user')->first())->not()->toBeNull();
expect(TenantCompany::whereGlobalId('company')->first())->not()->toBeNull();
tenancy()->end();
$tenant->users()->detach($centralUser);
$tenant->companies()->detach($centralCompany);
tenancy()->initialize($tenant);
expect(BaseTenantUser::whereGlobalId('user')->first())->toBeNull();
expect(TenantCompany::whereGlobalId('company')->first())->toBeNull();
});
function addTenantIdConstraintToPivot(string $pivotTable): void
{
Schema::table($pivotTable, function (Blueprint $table) {
$table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade');
});
}
/**
* Create two tenants and run migrations for those tenants.
*

View file

@ -56,6 +56,7 @@ test('file sessions are separated', function (bool $scopeSessions) {
if ($scopeSessions) {
expect($sessionPath())->toBe(storage_path('tenant' . $tenant->getTenantKey() . '/framework/sessions'));
expect(is_dir(storage_path('tenant' . $tenant->getTenantKey() . '/framework/sessions')))->toBeTrue();
} else {
expect($sessionPath())->toBe(storage_path('framework/sessions'));
}

View file

@ -23,6 +23,7 @@ use Stancl\Tenancy\UniqueIdentifierGenerators\RandomHexGenerator;
use Stancl\Tenancy\UniqueIdentifierGenerators\RandomIntGenerator;
use Stancl\Tenancy\UniqueIdentifierGenerators\RandomStringGenerator;
use Stancl\Tenancy\UniqueIdentifierGenerators\ULIDGenerator;
use Stancl\Tenancy\UniqueIdentifierGenerators\UUIDv7Generator;
use function Stancl\Tenancy\Tests\pest;
@ -94,6 +95,20 @@ test('ulid ids are supported', function () {
expect($tenant2->id > $tenant1->id)->toBeTrue();
});
test('uuidv7 ids are supported', function () {
app()->bind(UniqueIdentifierGenerator::class, UUIDv7Generator::class);
$tenant1 = Tenant::create();
expect($tenant1->id)->toBeString();
expect(strlen($tenant1->id))->toBe(36);
$tenant2 = Tenant::create();
expect($tenant2->id)->toBeString();
expect(strlen($tenant2->id))->toBe(36);
expect($tenant2->id > $tenant1->id)->toBeTrue();
});
test('hex ids are supported', function () {
app()->bind(UniqueIdentifierGenerator::class, RandomHexGenerator::class);

View file

@ -26,6 +26,7 @@ use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Exceptions\StatefulGuardRequiredException;
use function Stancl\Tenancy\Tests\pest;
use Symfony\Component\HttpKernel\Exception\HttpException;
beforeEach(function () {
pest()->artisan('migrate', [
@ -294,6 +295,117 @@ test('impersonation tokens can be created only with stateful guards', function (
->toBeInstanceOf(ImpersonationToken::class);
});
test('expired tokens are cleaned up before aborting', function () {
$tenant = Tenant::create();
migrateTenants();
$user = $tenant->run(function () {
return ImpersonationUser::create([
'name' => 'foo',
'email' => 'foo@bar',
'password' => bcrypt('password'),
]);
});
$token = tenancy()->impersonate($tenant, $user->id, '/dashboard');
// Make the token expired
$token->update([
'created_at' => Carbon::now()->subSeconds(100),
]);
expect(ImpersonationToken::find($token->token))->not()->toBeNull();
tenancy()->initialize($tenant);
// Try to use the expired token - should clean up and abort
expect(fn() => UserImpersonation::makeResponse($token->token))
->toThrow(HttpException::class); // Abort with 403
expect(ImpersonationToken::find($token->token))->toBeNull();
});
test('tokens are cleaned up when in wrong tenant context before aborting', function () {
$tenant1 = Tenant::create();
$tenant2 = Tenant::create();
migrateTenants();
$user = $tenant1->run(function () {
return ImpersonationUser::create([
'name' => 'foo',
'email' => 'foo@bar',
'password' => bcrypt('password'),
]);
});
$token = tenancy()->impersonate($tenant1, $user->id, '/dashboard');
expect(ImpersonationToken::find($token->token))->not->toBeNull();
tenancy()->initialize($tenant2);
// Try to use the token in wrong tenant context - should clean up and abort
expect(fn() => UserImpersonation::makeResponse($token->token))
->toThrow(HttpException::class); // Abort with 403
expect(ImpersonationToken::find($token->token))->toBeNull();
});
test('expired impersonation tokens can be cleaned up using a command', function () {
$tenant = Tenant::create();
migrateTenants();
$user = $tenant->run(function () {
return ImpersonationUser::create([
'name' => 'foo',
'email' => 'foo@bar',
'password' => bcrypt('password'),
]);
});
// Create tokens
$oldToken = tenancy()->impersonate($tenant, $user->id, '/dashboard');
$anotherOldToken = tenancy()->impersonate($tenant, $user->id, '/dashboard');
$activeToken = tenancy()->impersonate($tenant, $user->id, '/dashboard');
// Make two of the tokens expired by updating their created_at
$oldToken->update([
'created_at' => Carbon::now()->subSeconds(UserImpersonation::$ttl + 10),
]);
$anotherOldToken->update([
'created_at' => Carbon::now()->subSeconds(UserImpersonation::$ttl + 10),
]);
// All tokens exist
expect(ImpersonationToken::find($activeToken->token))->not()->toBeNull();
expect(ImpersonationToken::find($oldToken->token))->not()->toBeNull();
expect(ImpersonationToken::find($anotherOldToken->token))->not()->toBeNull();
pest()->artisan('tenants:purge-impersonation-tokens')
->assertExitCode(0)
->expectsOutputToContain('2 expired impersonation tokens deleted');
// The expired tokens were deleted
expect(ImpersonationToken::find($oldToken->token))->toBeNull();
expect(ImpersonationToken::find($anotherOldToken->token))->toBeNull();
// The active token still exists
expect(ImpersonationToken::find($activeToken->token))->not()->toBeNull();
// Update the active token to make it expired according to the default ttl (60s)
$activeToken->update([
'created_at' => Carbon::now()->subSeconds(70),
]);
// With ttl set to 80s, the active token should not be deleted (token is only considered expired if older than 80s)
UserImpersonation::$ttl = 80;
pest()->artisan('tenants:purge-impersonation-tokens')
->assertExitCode(0)
->expectsOutputToContain('0 expired impersonation tokens deleted');
expect(ImpersonationToken::find($activeToken->token))->not()->toBeNull();
});
function migrateTenants()
{
pest()->artisan('tenants:migrate')->assertExitCode(0);