mirror of
https://github.com/archtechx/tenancy.git
synced 2026-05-06 16:24:03 +00:00
Merge branch 'master' into add-log-bootstrapper
This commit is contained in:
commit
39fc72bea5
57 changed files with 1127 additions and 221 deletions
|
|
@ -103,6 +103,33 @@ test('central helper doesnt change tenancy state when called in central context'
|
|||
expect(tenant())->toBeNull();
|
||||
});
|
||||
|
||||
test('reinitialize method does nothing in the central context', function () {
|
||||
expect(tenancy()->initialized)->toBe(false);
|
||||
expect(fn () => tenancy()->reinitialize())->not()->toThrow(\Throwable::class);
|
||||
expect(tenancy()->initialized)->toBe(false);
|
||||
});
|
||||
|
||||
test('reinitialize method runs bootstrappers again for the current tenant', function () {
|
||||
config(['tenancy.bootstrappers' => [
|
||||
ReinitBootstrapper::class,
|
||||
]]);
|
||||
|
||||
tenancy()->initialize($tenant = Tenant::create(['reinit_bootstrapper_key' => 'foo']));
|
||||
|
||||
expect(tenant()->getKey())->toBe($tenant->getKey());
|
||||
expect(app('tenancy_reinit_bootstrapper_key'))->toBe('foo');
|
||||
|
||||
$tenant->update(['reinit_bootstrapper_key' => 'bar']);
|
||||
|
||||
// Unchanged until we reinitialize...
|
||||
expect(app('tenancy_reinit_bootstrapper_key'))->toBe('foo');
|
||||
|
||||
tenancy()->reinitialize();
|
||||
|
||||
expect(tenant()->getKey())->toBe($tenant->getKey());
|
||||
expect(app('tenancy_reinit_bootstrapper_key'))->toBe('bar');
|
||||
});
|
||||
|
||||
class MyBootstrapper implements TenancyBootstrapper
|
||||
{
|
||||
public function bootstrap(\Stancl\Tenancy\Contracts\Tenant $tenant): void
|
||||
|
|
@ -115,3 +142,16 @@ class MyBootstrapper implements TenancyBootstrapper
|
|||
app()->instance('tenancy_ended', true);
|
||||
}
|
||||
}
|
||||
|
||||
class ReinitBootstrapper implements TenancyBootstrapper
|
||||
{
|
||||
public function bootstrap(\Stancl\Tenancy\Contracts\Tenant $tenant): void
|
||||
{
|
||||
app()->instance('tenancy_reinit_bootstrapper_key', $tenant->getAttribute('reinit_bootstrapper_key'));
|
||||
}
|
||||
|
||||
public function revert(): void
|
||||
{
|
||||
app()->instance('tenancy_reinit_bootstrapper_key', null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -200,3 +200,60 @@ 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' => [
|
||||
FilesystemTenancyBootstrapper::class,
|
||||
],
|
||||
'filesystems.disks.scoped_disk' => [
|
||||
'driver' => 'scoped',
|
||||
'disk' => 'public',
|
||||
'prefix' => 'scoped_disk_prefix',
|
||||
],
|
||||
]);
|
||||
|
||||
$tenant = Tenant::create();
|
||||
|
||||
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);
|
||||
|
||||
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(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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Routing\UrlGenerator;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
|
@ -18,7 +19,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 () {
|
||||
|
|
@ -26,12 +26,16 @@ beforeEach(function () {
|
|||
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||
TenancyUrlGenerator::$prefixRouteNames = false;
|
||||
TenancyUrlGenerator::$passTenantParameterToRoutes = false;
|
||||
TenancyUrlGenerator::$overrides = [];
|
||||
TenancyUrlGenerator::$bypassParameter = 'central';
|
||||
UrlGeneratorBootstrapper::$addTenantParameterToDefaults = false;
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
TenancyUrlGenerator::$prefixRouteNames = false;
|
||||
TenancyUrlGenerator::$passTenantParameterToRoutes = false;
|
||||
TenancyUrlGenerator::$overrides = [];
|
||||
TenancyUrlGenerator::$bypassParameter = 'central';
|
||||
UrlGeneratorBootstrapper::$addTenantParameterToDefaults = false;
|
||||
});
|
||||
|
||||
|
|
@ -80,6 +84,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]]);
|
||||
|
||||
|
|
@ -322,3 +364,40 @@ test('both the name prefixing and the tenant parameter logic gets skipped when b
|
|||
expect(route('home', ['bypassParameter' => false, 'tenant' => $tenant->getTenantKey()]))->toBe($tenantRouteUrl)
|
||||
->not()->toContain('bypassParameter');
|
||||
});
|
||||
|
||||
test('the temporarySignedRoute method can automatically prefix the passed route name', function() {
|
||||
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
|
||||
|
||||
Route::get('/{tenant}/foo', fn () => 'foo')->name('tenant.foo')->middleware([InitializeTenancyByPath::class]);
|
||||
|
||||
TenancyUrlGenerator::$prefixRouteNames = true;
|
||||
|
||||
$tenant = Tenant::create();
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
// Route name ('foo') gets prefixed automatically (will be 'tenant.foo')
|
||||
$tenantSignedUrl = URL::temporarySignedRoute('foo', now()->addMinutes(2), ['tenant' => $tenantKey = $tenant->getTenantKey()]);
|
||||
|
||||
expect($tenantSignedUrl)->toContain("localhost/{$tenantKey}/foo");
|
||||
});
|
||||
|
||||
test('the bypass parameter works correctly with temporarySignedRoute', function() {
|
||||
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
|
||||
|
||||
Route::get('/foo', fn () => 'foo')->name('central.foo');
|
||||
|
||||
TenancyUrlGenerator::$prefixRouteNames = true;
|
||||
TenancyUrlGenerator::$bypassParameter = 'central';
|
||||
|
||||
$tenant = Tenant::create();
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
// Bypass parameter allows us to generate URL for the 'central.foo' route in tenant context
|
||||
$centralSignedUrl = URL::temporarySignedRoute('central.foo', now()->addMinutes(2), ['central' => true]);
|
||||
|
||||
expect($centralSignedUrl)
|
||||
->toContain('localhost/foo')
|
||||
->not()->toContain('central='); // Bypass parameter gets removed from the generated URL
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use Stancl\Tenancy\ResourceSyncing\SyncMaster;
|
|||
class CentralUser extends Model implements SyncMaster
|
||||
{
|
||||
use ResourceSyncing, CentralConnection;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
public $timestamps = false;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Stancl\Tenancy\Tests\Etc;
|
||||
|
||||
use Closure;
|
||||
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
|
||||
use Stancl\Tenancy\Database\Concerns\HasDatabase;
|
||||
use Stancl\Tenancy\Database\Concerns\HasDomains;
|
||||
|
|
@ -16,6 +17,7 @@ use Stancl\Tenancy\Database\Models;
|
|||
class Tenant extends Models\Tenant implements TenantWithDatabase
|
||||
{
|
||||
public static array $extraCustomColumns = [];
|
||||
public static ?Closure $getPendingAttributesUsing = null;
|
||||
|
||||
use HasDatabase, HasDomains, HasPending;
|
||||
|
||||
|
|
@ -23,4 +25,9 @@ class Tenant extends Models\Tenant implements TenantWithDatabase
|
|||
{
|
||||
return array_merge(parent::getCustomColumns(), static::$extraCustomColumns);
|
||||
}
|
||||
|
||||
public static function getPendingAttributes(array $attributes): array
|
||||
{
|
||||
return static::$getPendingAttributesUsing ? (static::$getPendingAttributesUsing)($attributes) : [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,12 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Stancl\Tenancy\Commands\ClearPendingTenants;
|
||||
use Stancl\Tenancy\Commands\CreatePendingTenants;
|
||||
use Stancl\Tenancy\Events\CreatingPendingTenant;
|
||||
|
|
@ -13,6 +17,13 @@ use Stancl\Tenancy\Events\PullingPendingTenant;
|
|||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
beforeEach($cleanup = function () {
|
||||
Tenant::$extraCustomColumns = [];
|
||||
Tenant::$getPendingAttributesUsing = null;
|
||||
});
|
||||
|
||||
afterEach($cleanup);
|
||||
|
||||
test('tenants are correctly identified as pending', function (){
|
||||
Tenant::createPending();
|
||||
|
||||
|
|
@ -191,3 +202,25 @@ test('commands run for pending tenants too if the with pending option is passed'
|
|||
|
||||
$artisan->assertExitCode(0);
|
||||
});
|
||||
|
||||
test('pending tenants can have default attributes for non-nullable columns', function (bool $withPendingAttributes) {
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
$table->string('slug')->unique();
|
||||
});
|
||||
|
||||
Tenant::$extraCustomColumns = ['slug'];
|
||||
if ($withPendingAttributes) Tenant::$getPendingAttributesUsing = fn () => [
|
||||
'slug' => Str::random(8),
|
||||
];
|
||||
|
||||
$fn = fn () => Tenant::createPending();
|
||||
|
||||
// If there are non-nullable custom columns, and createPending() is called
|
||||
// on its own without any values passed for those columns (as it would be called
|
||||
// by the tenants:pending-create artisan command), we expect it to fail, unless
|
||||
// getPendingAttributes() provides default values for those custom columns.
|
||||
if ($withPendingAttributes)
|
||||
expect($fn)->not()->toThrow(QueryException::class);
|
||||
else
|
||||
expect($fn)->toThrow(QueryException::class);
|
||||
})->with([true, false]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
|||
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
|
||||
use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
// todo@tests write similar low-level tests for the cache bootstrapper? including the database driver in a single-db setup
|
||||
|
|
@ -56,6 +57,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'));
|
||||
}
|
||||
|
|
@ -99,7 +101,7 @@ test('redis sessions are separated using the redis bootstrapper', function (bool
|
|||
expect($redisClient->getOption($redisClient::OPT_PREFIX) === "tenant_{$tenant->id}_")->toBe($bootstrappedEnabled);
|
||||
|
||||
expect(array_filter(Redis::keys('*'), function (string $key) use ($tenant) {
|
||||
return str($key)->startsWith("tenant_{$tenant->id}_laravel_cache_");
|
||||
return str($key)->startsWith(formatLaravelCacheKey(prefix: "tenant_{$tenant->id}_"));
|
||||
}))->toHaveCount($bootstrappedEnabled ? 1 : 0);
|
||||
})->with([true, false]);
|
||||
|
||||
|
|
@ -117,13 +119,13 @@ test('redis sessions are separated using the cache bootstrapper', function (bool
|
|||
Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar');
|
||||
pest()->get("/{$tenant->id}/foo");
|
||||
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === "laravel_cache_tenant_{$tenant->id}_")->toBe($scopeSessions);
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === formatLaravelCacheKey("tenant_{$tenant->id}_"))->toBe($scopeSessions);
|
||||
|
||||
tenancy()->end();
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe('laravel_cache_');
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe(formatLaravelCacheKey());
|
||||
|
||||
expect(array_filter(Redis::keys('*'), function (string $key) use ($tenant) {
|
||||
return str($key)->startsWith("foolaravel_cache_tenant_{$tenant->id}");
|
||||
return str($key)->startsWith(formatLaravelCacheKey(prefix: 'foo', suffix: "tenant_{$tenant->id}"));
|
||||
}))->toHaveCount($scopeSessions ? 1 : 0);
|
||||
})->with([true, false]);
|
||||
|
||||
|
|
@ -147,14 +149,14 @@ test('memcached sessions are separated using the cache bootstrapper', function (
|
|||
Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar');
|
||||
pest()->get("/{$tenant->id}/foo");
|
||||
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === "laravel_cache_tenant_{$tenant->id}_")->toBe($scopeSessions);
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === formatLaravelCacheKey("tenant_{$tenant->id}_"))->toBe($scopeSessions);
|
||||
|
||||
tenancy()->end();
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe('laravel_cache_');
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe(formatLaravelCacheKey());
|
||||
|
||||
sleep(1.1); // 1s+ sleep is necessary for getAllKeys() to work. if this causes race conditions or we want to avoid the delay, we can refactor this to some type of a mock
|
||||
expect(array_filter($allMemcachedKeys(), function (string $key) use ($tenant) {
|
||||
return str($key)->startsWith("laravel_cache_tenant_{$tenant->id}");
|
||||
return str($key)->startsWith(formatLaravelCacheKey("tenant_{$tenant->id}"));
|
||||
}))->toHaveCount($scopeSessions ? 1 : 0);
|
||||
|
||||
Artisan::call('cache:clear memcached');
|
||||
|
|
@ -176,13 +178,13 @@ test('dynamodb sessions are separated using the cache bootstrapper', function (b
|
|||
Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar');
|
||||
pest()->get("/{$tenant->id}/foo");
|
||||
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === "laravel_cache_tenant_{$tenant->id}_")->toBe($scopeSessions);
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === formatLaravelCacheKey("tenant_{$tenant->id}_"))->toBe($scopeSessions);
|
||||
|
||||
tenancy()->end();
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe('laravel_cache_');
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe(formatLaravelCacheKey());
|
||||
|
||||
expect(array_filter($allDynamodbKeys(), function (string $key) use ($tenant) {
|
||||
return str($key)->startsWith("laravel_cache_tenant_{$tenant->id}");
|
||||
return str($key)->startsWith(formatLaravelCacheKey("tenant_{$tenant->id}"));
|
||||
}))->toHaveCount($scopeSessions ? 1 : 0);
|
||||
})->with([true, false]);
|
||||
|
||||
|
|
@ -201,13 +203,13 @@ test('apc sessions are separated using the cache bootstrapper', function (bool $
|
|||
Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar');
|
||||
pest()->get("/{$tenant->id}/foo");
|
||||
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === "laravel_cache_tenant_{$tenant->id}_")->toBe($scopeSessions);
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === formatLaravelCacheKey("tenant_{$tenant->id}_"))->toBe($scopeSessions);
|
||||
|
||||
tenancy()->end();
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe('laravel_cache_');
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe(formatLaravelCacheKey());
|
||||
|
||||
expect(array_filter($allApcuKeys(), function (string $key) use ($tenant) {
|
||||
return str($key)->startsWith("laravel_cache_tenant_{$tenant->id}");
|
||||
return str($key)->startsWith(formatLaravelCacheKey("tenant_{$tenant->id}"));
|
||||
}))->toHaveCount($scopeSessions ? 1 : 0);
|
||||
})->with([true, false]);
|
||||
|
||||
|
|
@ -249,3 +251,13 @@ test('database sessions are separated regardless of whether the session bootstra
|
|||
// [false, true], // when the connection IS set, the session bootstrapper becomes necessary
|
||||
[false, false],
|
||||
]);
|
||||
|
||||
function formatLaravelCacheKey(string $suffix = '', string $prefix = ''): string
|
||||
{
|
||||
// todo@release if we drop Laravel 12 support we can just switch to - syntax everywhere
|
||||
if (version_compare(app()->version(), '13.0.0') >= 0) {
|
||||
return $prefix . 'laravel-cache-' . $suffix;
|
||||
} else {
|
||||
return $prefix . 'laravel_cache_' . $suffix;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue