mirror of
https://github.com/archtechx/tenancy.git
synced 2026-06-20 22:54:05 +00:00
Merge branch 'master' into fix-cache-invalidation
This commit is contained in:
commit
c6ba6a574c
10 changed files with 413 additions and 88 deletions
|
|
@ -16,7 +16,7 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver;
|
|||
/**
|
||||
* Makes the app use TenancyUrlGenerator (instead of Illuminate\Routing\UrlGenerator) which:
|
||||
* - prefixes route names with the tenant route name prefix (PathTenantResolver::tenantRouteNamePrefix() by default)
|
||||
* - passes the tenant parameter to the link generated by route() and temporarySignedRoute() (PathTenantResolver::tenantParameterName() by default).
|
||||
* - passes the tenant parameter (PathTenantResolver::tenantParameterName() by default) to the link generated by the affected methods like route() and temporarySignedRoute().
|
||||
*
|
||||
* Used with path and query string identification.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ trait HasTenantOptions
|
|||
{
|
||||
return array_merge([
|
||||
new InputOption('tenants', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_OPTIONAL, 'The tenants to run this command for. Leave empty for all tenants', null),
|
||||
new InputOption('with-pending', null, InputOption::VALUE_NONE, 'Include pending tenants in query'), // todo@pending should we also offer without-pending? if we add this, mention in docs
|
||||
new InputOption('with-pending', null, InputOption::VALUE_OPTIONAL, 'Include pending tenants in query if true/1, exclude if false/0. Defaults to the tenancy.pending.include_in_queries config value.'),
|
||||
], parent::getOptions());
|
||||
}
|
||||
|
||||
|
|
@ -43,7 +43,11 @@ trait HasTenantOptions
|
|||
$query->whereIn(tenancy()->model()->getTenantKeyName(), $this->option('tenants'));
|
||||
})
|
||||
->when(tenancy()->model()::hasGlobalScope(PendingScope::class), function ($query) {
|
||||
$query->withPending(config('tenancy.pending.include_in_queries') ?: $this->option('with-pending'));
|
||||
$includePending = $this->input->hasParameterOption('--with-pending')
|
||||
? filter_var($this->option('with-pending') ?? true, FILTER_VALIDATE_BOOLEAN)
|
||||
: config('tenancy.pending.include_in_queries');
|
||||
|
||||
$query->withPending($includePending);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,18 @@ trait HasPending
|
|||
public static function bootHasPending(): void
|
||||
{
|
||||
static::addGlobalScope(new PendingScope());
|
||||
|
||||
static::creating(function (self $tenant): void {
|
||||
if ($tenant->pending()) {
|
||||
event(new CreatingPendingTenant($tenant));
|
||||
}
|
||||
});
|
||||
|
||||
static::created(function (self $tenant): void {
|
||||
if ($tenant->pending()) {
|
||||
event(new PendingTenantCreated($tenant));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Initialize the trait. */
|
||||
|
|
@ -49,22 +61,11 @@ trait HasPending
|
|||
*/
|
||||
public static function createPending(array $attributes = []): Model&Tenant
|
||||
{
|
||||
$tenant = null;
|
||||
|
||||
try {
|
||||
$tenant = static::create(array_merge(static::getPendingAttributes($attributes), $attributes));
|
||||
event(new CreatingPendingTenant($tenant));
|
||||
} finally {
|
||||
// Update the pending_since value only after the tenant is created so it's
|
||||
// not marked as pending until after migrations, seeders, etc are run.
|
||||
$tenant?->update([
|
||||
'pending_since' => now()->timestamp,
|
||||
]);
|
||||
}
|
||||
|
||||
event(new PendingTenantCreated($tenant));
|
||||
|
||||
return $tenant;
|
||||
return static::create(array_merge(
|
||||
static::getPendingAttributes($attributes),
|
||||
$attributes,
|
||||
['pending_since' => now()->timestamp],
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Stancl\Tenancy\Features;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
|
@ -61,9 +62,9 @@ class UserImpersonation implements Feature
|
|||
|
||||
Auth::guard($token->auth_guard)->loginUsingId($token->user_id, $token->remember);
|
||||
|
||||
$token->delete();
|
||||
session()->put('tenancy_impersonation_guard', $token->auth_guard);
|
||||
|
||||
session()->put('tenancy_impersonating', true);
|
||||
$token->delete();
|
||||
|
||||
return redirect($token->redirect_url);
|
||||
}
|
||||
|
|
@ -76,16 +77,30 @@ class UserImpersonation implements Feature
|
|||
|
||||
public static function isImpersonating(): bool
|
||||
{
|
||||
return session()->has('tenancy_impersonating');
|
||||
return session()->has('tenancy_impersonation_guard');
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout from the current domain and forget impersonation session.
|
||||
* Stop user impersonation by forgetting the impersonation session.
|
||||
*
|
||||
* When $logout is true, the user will also be logged out
|
||||
* from the impersonation guard stored in the session.
|
||||
*
|
||||
* Throws an exception if impersonation is not active
|
||||
* (= the impersonation guard is not in the session).
|
||||
*/
|
||||
public static function stopImpersonating(): void
|
||||
public static function stopImpersonating(bool $logout = true): void
|
||||
{
|
||||
auth()->logout();
|
||||
if (! static::isImpersonating()) {
|
||||
throw new Exception('Not currently impersonating any user.');
|
||||
}
|
||||
|
||||
session()->forget('tenancy_impersonating');
|
||||
if ($logout) {
|
||||
$guard = session()->get('tenancy_impersonation_guard');
|
||||
|
||||
auth($guard)->logout();
|
||||
}
|
||||
|
||||
session()->forget('tenancy_impersonation_guard');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,16 @@ class MigrateDatabase implements ShouldQueue
|
|||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Should pending tenants be included while migrating,
|
||||
* regardless of the tenancy.pending.include_in_queries config value.
|
||||
*
|
||||
* If false, pending tenants will be specifically excluded.
|
||||
*
|
||||
* If null, default to tenancy.pending.include_in_queries config.
|
||||
*/
|
||||
public static ?bool $includePending = true;
|
||||
|
||||
public function __construct(
|
||||
protected TenantWithDatabase&Model $tenant,
|
||||
) {}
|
||||
|
|
@ -25,6 +35,7 @@ class MigrateDatabase implements ShouldQueue
|
|||
{
|
||||
Artisan::call('tenants:migrate', [
|
||||
'--tenants' => [$this->tenant->getTenantKey()],
|
||||
'--with-pending' => static::$includePending ?? config('tenancy.pending.include_in_queries'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,16 @@ class SeedDatabase implements ShouldQueue
|
|||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Should pending tenants be included while seeding,
|
||||
* regardless of the tenancy.pending.include_in_queries config value.
|
||||
*
|
||||
* If false, pending tenants will be specifically excluded.
|
||||
*
|
||||
* If null, default to tenancy.pending.include_in_queries config.
|
||||
*/
|
||||
public static ?bool $includePending = true;
|
||||
|
||||
public function __construct(
|
||||
protected TenantWithDatabase&Model $tenant,
|
||||
) {}
|
||||
|
|
@ -25,6 +35,7 @@ class SeedDatabase implements ShouldQueue
|
|||
{
|
||||
Artisan::call('tenants:seed', [
|
||||
'--tenants' => [$this->tenant->getTenantKey()],
|
||||
'--with-pending' => static::$includePending ?? config('tenancy.pending.include_in_queries'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,16 +22,18 @@ use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
|
|||
* - Automatically passing ['tenant' => ...] to each route() call -- if TenancyUrlGenerator::$passTenantParameterToRoutes is enabled
|
||||
* This is a more universal solution since it supports both path identification and query parameter identification.
|
||||
*
|
||||
* - Prepends route names passed to route() and URL::temporarySignedRoute()
|
||||
* with `tenant.` (or the configured prefix) if $prefixRouteNames is enabled.
|
||||
* - Prepends route names with the tenant route name prefix ('tenant.' by default,
|
||||
* configurable at tenant_route_name_prefix under PathTenantResolver) if $prefixRouteNames is enabled.
|
||||
* This is primarily useful when using route cloning with path identification.
|
||||
*
|
||||
* To bypass this behavior on any single route() call, pass the $bypassParameter as true (['central' => true] by default).
|
||||
* Affected methods: route(), toRoute(), temporarySignedRoute(), signedRoute() (the last two via the route() override).
|
||||
*
|
||||
* To bypass this behavior on any single affected method call, pass the $bypassParameter as true (['central' => true] by default).
|
||||
*/
|
||||
class TenancyUrlGenerator extends UrlGenerator
|
||||
{
|
||||
/**
|
||||
* Parameter which works as a flag for bypassing the behavior modification of route() and temporarySignedRoute().
|
||||
* Parameter which works as a flag for bypassing the behavior modification of the affected methods.
|
||||
*
|
||||
* For example, in tenant context:
|
||||
* Route::get('/', ...)->name('home');
|
||||
|
|
@ -44,12 +46,12 @@ class TenancyUrlGenerator extends UrlGenerator
|
|||
* Note: UrlGeneratorBootstrapper::$addTenantParameterToDefaults is not affected by this, though
|
||||
* it doesn't matter since it doesn't pass any extra parameters when not needed.
|
||||
*
|
||||
* @see UrlGeneratorBootstrapper
|
||||
* @see Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper
|
||||
*/
|
||||
public static string $bypassParameter = 'central';
|
||||
|
||||
/**
|
||||
* Should route names passed to route() or temporarySignedRoute()
|
||||
* Should route names passed to the affected methods
|
||||
* get prefixed with the tenant route name prefix.
|
||||
*
|
||||
* This is useful when using e.g. path identification with third-party packages
|
||||
|
|
@ -59,12 +61,12 @@ class TenancyUrlGenerator extends UrlGenerator
|
|||
public static bool $prefixRouteNames = false;
|
||||
|
||||
/**
|
||||
* Should the tenant parameter be passed to route() or temporarySignedRoute() calls.
|
||||
* Should the tenant parameter be passed to the affected methods.
|
||||
*
|
||||
* This is useful with path or query parameter identification. The former can be handled
|
||||
* more elegantly using UrlGeneratorBootstrapper::$addTenantParameterToDefaults.
|
||||
*
|
||||
* @see UrlGeneratorBootstrapper
|
||||
* @see Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper
|
||||
*/
|
||||
public static bool $passTenantParameterToRoutes = false;
|
||||
|
||||
|
|
@ -105,8 +107,18 @@ class TenancyUrlGenerator extends UrlGenerator
|
|||
public static bool $passQueryParameter = true;
|
||||
|
||||
/**
|
||||
* Override the route() method so that the route name gets prefixed
|
||||
* and the tenant parameter gets added when in tenant context.
|
||||
* Override the route() method to prefix the route name before $this->routes->getByName($name) is called
|
||||
* in the parent route() call.
|
||||
*
|
||||
* This is necessary because $this->routes->getByName($name) is called to retrieve the route
|
||||
* before passing it to toRoute(). If only the prefixed route (e.g. 'tenant.foo') is registered
|
||||
* and the original ('foo') isn't, route() would throw a RouteNotFoundException.
|
||||
* So route() has to be overridden to prefix the passed route name, even though toRoute() is overridden already.
|
||||
*
|
||||
* Only the name is taken from prepareRouteInputs() here — parameter handling
|
||||
* (adding tenant parameter, removing bypass parameter) is delegated to toRoute().
|
||||
*
|
||||
* Affects temporarySignedRoute() and signedRoute() as well since they call route() under the hood.
|
||||
*/
|
||||
public function route($name, $parameters = [], $absolute = true)
|
||||
{
|
||||
|
|
@ -114,32 +126,28 @@ class TenancyUrlGenerator extends UrlGenerator
|
|||
throw new InvalidArgumentException('Attribute [name] expects a string backed enum.');
|
||||
}
|
||||
|
||||
[$name, $parameters] = $this->prepareRouteInputs($name, Arr::wrap($parameters)); // @phpstan-ignore argument.type
|
||||
[$name] = $this->prepareRouteInputs(Arr::wrap($parameters), $name); // @phpstan-ignore argument.type
|
||||
|
||||
return parent::route($name, $parameters, $absolute);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the temporarySignedRoute() method so that the route name gets prefixed
|
||||
* and the tenant parameter gets added when in tenant context.
|
||||
* Override the toRoute() to prefix the route name
|
||||
* and add the tenant parameter when in tenant context.
|
||||
*
|
||||
* Also affects route(). Even though route() is overridden separately, it delegates parameter handling to toRoute().
|
||||
*/
|
||||
public function temporarySignedRoute($name, $expiration, $parameters = [], $absolute = true)
|
||||
public function toRoute($route, $parameters, $absolute)
|
||||
{
|
||||
if ($name instanceof BackedEnum && ! is_string($name = $name->value)) {
|
||||
throw new InvalidArgumentException('Attribute [name] expects a string backed enum.');
|
||||
$name = $route->getName();
|
||||
|
||||
[$prefixedName, $parameters] = $this->prepareRouteInputs(Arr::wrap($parameters), $name);
|
||||
|
||||
if ($name && $prefixedName !== $name && $tenantRoute = $this->routes->getByName($prefixedName)) {
|
||||
$route = $tenantRoute;
|
||||
}
|
||||
|
||||
$wrappedParameters = Arr::wrap($parameters);
|
||||
|
||||
[$name, $parameters] = $this->prepareRouteInputs($name, $wrappedParameters); // @phpstan-ignore argument.type
|
||||
|
||||
if (isset($wrappedParameters[static::$bypassParameter])) {
|
||||
// If the bypass parameter was passed, we need to add it back to the parameters after prepareRouteInputs() removes it,
|
||||
// so that the underlying route() call in parent::temporarySignedRoute() can bypass the behavior modification as well.
|
||||
$parameters[static::$bypassParameter] = $wrappedParameters[static::$bypassParameter];
|
||||
}
|
||||
|
||||
return parent::temporarySignedRoute($name, $expiration, $parameters, $absolute);
|
||||
return parent::toRoute($route, $parameters, $absolute);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -155,16 +163,19 @@ class TenancyUrlGenerator extends UrlGenerator
|
|||
}
|
||||
|
||||
/**
|
||||
* Takes a route name and an array of parameters to return the prefixed route name
|
||||
* Takes an array of parameters and a route name to return the prefixed route name
|
||||
* and the route parameters with the tenant parameter added.
|
||||
*
|
||||
* To skip these modifications, pass the bypass parameter in route parameters.
|
||||
* Before returning the modified route inputs, the bypass parameter is removed from the parameters.
|
||||
*/
|
||||
protected function prepareRouteInputs(string $name, array $parameters): array
|
||||
protected function prepareRouteInputs(array $parameters, string|null $name): array
|
||||
{
|
||||
if (! $this->routeBehaviorModificationBypassed($parameters)) {
|
||||
if (! is_null($name)) {
|
||||
$name = $this->routeNameOverride($name) ?? $this->prefixRouteName($name);
|
||||
}
|
||||
|
||||
$parameters = $this->addTenantParameter($parameters);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -401,3 +401,47 @@ test('the bypass parameter works correctly with temporarySignedRoute', function(
|
|||
->toContain('localhost/foo')
|
||||
->not()->toContain('central='); // Bypass parameter gets removed from the generated URL
|
||||
});
|
||||
|
||||
test('toRoute can automatically prefix the passed route name', function () {
|
||||
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
|
||||
|
||||
Route::get('/central/home', fn () => 'central')->name('home');
|
||||
Route::get('/tenant/home', fn () => 'tenant')->name('tenant.home');
|
||||
|
||||
TenancyUrlGenerator::$prefixRouteNames = true;
|
||||
|
||||
$tenant = Tenant::create();
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
$centralRoute = Route::getRoutes()->getByName('home');
|
||||
|
||||
// url()->toRoute() prefixes the name of the passed route ('home') with the tenant prefix
|
||||
// and generates the URL for the tenant route (as if the 'tenant.home' route was passed to the method)
|
||||
expect(url()->toRoute($centralRoute, [], true))->toBe('http://localhost/tenant/home');
|
||||
|
||||
// Passing the bypass parameter skips the name prefixing, so the method returns the central route URL
|
||||
expect(url()->toRoute($centralRoute, ['central' => true], true))->toBe('http://localhost/central/home');
|
||||
});
|
||||
|
||||
test('toRoute modifies parameters even when the route has no name', function () {
|
||||
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
|
||||
|
||||
TenancyUrlGenerator::$passTenantParameterToRoutes = true;
|
||||
|
||||
$unnamedRoute = Route::get('/unnamed', fn () => 'unnamed');
|
||||
|
||||
$tenant = Tenant::create();
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
// The tenant parameter is added to the URL even for unnamed routes
|
||||
expect(url()->toRoute($unnamedRoute, [], true))
|
||||
->toBe("http://localhost/unnamed?tenant={$tenant->getTenantKey()}");
|
||||
|
||||
// The bypass parameter prevents passing the tenant parameter and is stripped from the URL
|
||||
expect(url()->toRoute($unnamedRoute, ['central' => true], true))
|
||||
->toBe("http://localhost/unnamed")
|
||||
->not()->toContain('tenant=')
|
||||
->not()->toContain('central=');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,10 +16,25 @@ use Stancl\Tenancy\Events\PendingTenantPulled;
|
|||
use Stancl\Tenancy\Events\PullingPendingTenant;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
use Stancl\Tenancy\Events\TenantCreated;
|
||||
use Stancl\JobPipeline\JobPipeline;
|
||||
use Stancl\Tenancy\Jobs\CreateDatabase;
|
||||
use Stancl\Tenancy\Jobs\MigrateDatabase;
|
||||
use Stancl\Tenancy\Jobs\SeedDatabase;
|
||||
use Stancl\Tenancy\Tests\Etc\User;
|
||||
use Stancl\Tenancy\Tests\Etc\TestSeeder;
|
||||
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||
use Stancl\Tenancy\Events\TenancyEnded;
|
||||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
|
||||
beforeEach($cleanup = function () {
|
||||
Tenant::$extraCustomColumns = [];
|
||||
Tenant::$getPendingAttributesUsing = null;
|
||||
|
||||
MigrateDatabase::$includePending = true;
|
||||
SeedDatabase::$includePending = true;
|
||||
});
|
||||
|
||||
afterEach($cleanup);
|
||||
|
|
@ -154,8 +169,8 @@ test('pending events are dispatched', function () {
|
|||
Event::assertDispatched(PendingTenantPulled::class);
|
||||
});
|
||||
|
||||
test('commands do not run for pending tenants if tenancy.pending.include_in_queries is false and the with pending option does not get passed', function() {
|
||||
config(['tenancy.pending.include_in_queries' => false]);
|
||||
test('commands include tenants based on the include_in_queries config when --with-pending is not passed', function (bool $includeInQueries) {
|
||||
config(['tenancy.pending.include_in_queries' => $includeInQueries]);
|
||||
|
||||
$tenants = collect([
|
||||
Tenant::create(),
|
||||
|
|
@ -164,21 +179,21 @@ test('commands do not run for pending tenants if tenancy.pending.include_in_quer
|
|||
Tenant::createPending(),
|
||||
]);
|
||||
|
||||
pest()->artisan('tenants:migrate --with-pending');
|
||||
$command = pest()->artisan("tenants:run 'bar testing testing@test.test password foo'");
|
||||
|
||||
$artisan = pest()->artisan("tenants:run 'foo foo --b=bar --c=xyz'");
|
||||
$tenants->each(function ($tenant) use ($command, $includeInQueries) {
|
||||
if ($tenant->pending() && ! $includeInQueries) {
|
||||
$command->doesntExpectOutputToContain("Tenant: {$tenant->getTenantKey()}");
|
||||
} else {
|
||||
$command->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}");
|
||||
}
|
||||
});
|
||||
|
||||
$pendingTenants = $tenants->filter->pending();
|
||||
$readyTenants = $tenants->reject->pending();
|
||||
$command->assertSuccessful();
|
||||
})->with([true, false]);
|
||||
|
||||
$pendingTenants->each(fn ($tenant) => $artisan->doesntExpectOutputToContain("Tenant: {$tenant->getTenantKey()}"));
|
||||
$readyTenants->each(fn ($tenant) => $artisan->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}"));
|
||||
|
||||
$artisan->assertExitCode(0);
|
||||
});
|
||||
|
||||
test('commands run for pending tenants too if tenancy.pending.include_in_queries is true', function() {
|
||||
config(['tenancy.pending.include_in_queries' => true]);
|
||||
test('commands include pending tenants when truthy --with-pending is passed', function (bool $includeInQueries) {
|
||||
config(['tenancy.pending.include_in_queries' => $includeInQueries]);
|
||||
|
||||
$tenants = collect([
|
||||
Tenant::create(),
|
||||
|
|
@ -187,17 +202,22 @@ test('commands run for pending tenants too if tenancy.pending.include_in_queries
|
|||
Tenant::createPending(),
|
||||
]);
|
||||
|
||||
pest()->artisan('tenants:migrate --with-pending');
|
||||
foreach ([
|
||||
'--with-pending',
|
||||
'--with-pending=true',
|
||||
'--with-pending=1'
|
||||
] as $option) {
|
||||
$command = pest()->artisan("tenants:run 'bar testing testing@test.test password foo' {$option}");
|
||||
|
||||
$artisan = pest()->artisan("tenants:run 'foo foo --b=bar --c=xyz'");
|
||||
// Pending tenants are included regardless of tenancy.pending.include_in_queries
|
||||
$tenants->each(fn ($tenant) => $command->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}"));
|
||||
|
||||
$tenants->each(fn ($tenant) => $artisan->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}"));
|
||||
$command->assertSuccessful();
|
||||
}
|
||||
})->with([true, false]);
|
||||
|
||||
$artisan->assertExitCode(0);
|
||||
});
|
||||
|
||||
test('commands run for pending tenants too if the with pending option is passed', function() {
|
||||
config(['tenancy.pending.include_in_queries' => false]);
|
||||
test('commands exclude pending tenants when falsy --with-pending is passed', function (bool $includeInQueries) {
|
||||
config(['tenancy.pending.include_in_queries' => $includeInQueries]);
|
||||
|
||||
$tenants = collect([
|
||||
Tenant::create(),
|
||||
|
|
@ -206,14 +226,25 @@ test('commands run for pending tenants too if the with pending option is passed'
|
|||
Tenant::createPending(),
|
||||
]);
|
||||
|
||||
pest()->artisan('tenants:migrate --with-pending');
|
||||
foreach ([
|
||||
'--with-pending=false',
|
||||
'--with-pending=0',
|
||||
'--with-pending=foo' // Invalid values are treated as false
|
||||
] as $option) {
|
||||
$command = pest()->artisan("tenants:run 'bar testing testing@test.test password foo' {$option}");
|
||||
|
||||
$artisan = pest()->artisan("tenants:run 'foo foo --b=bar --c=xyz' --with-pending");
|
||||
$tenants->each(function ($tenant) use ($command) {
|
||||
if ($tenant->pending()) {
|
||||
// Pending tenants are excluded regardless of tenancy.pending.include_in_queries
|
||||
$command->doesntExpectOutputToContain("Tenant: {$tenant->getTenantKey()}");
|
||||
} else {
|
||||
$command->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}");
|
||||
}
|
||||
});
|
||||
|
||||
$tenants->each(fn ($tenant) => $artisan->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}"));
|
||||
|
||||
$artisan->assertExitCode(0);
|
||||
});
|
||||
$command->assertSuccessful();
|
||||
}
|
||||
})->with([true, false]);
|
||||
|
||||
test('pending tenants can have default attributes for non-nullable columns', function (bool $withPendingAttributes) {
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
|
|
@ -236,3 +267,105 @@ test('pending tenants can have default attributes for non-nullable columns', fun
|
|||
else
|
||||
expect($fn)->toThrow(QueryException::class);
|
||||
})->with([true, false]);
|
||||
|
||||
test('pending tenant databases can be migrated using a job unless configured otherwise', function (bool $includeInQueries, ?bool $migrateWithPending) {
|
||||
config([
|
||||
'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class],
|
||||
'tenancy.pending.include_in_queries' => $includeInQueries,
|
||||
]);
|
||||
|
||||
MigrateDatabase::$includePending = $migrateWithPending;
|
||||
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||
Event::listen(TenantCreated::class, JobPipeline::make([
|
||||
CreateDatabase::class,
|
||||
MigrateDatabase::class,
|
||||
])->send(function (TenantCreated $event) {
|
||||
return $event->tenant;
|
||||
})->toListener());
|
||||
|
||||
$pendingTenant = Tenant::createPending();
|
||||
|
||||
expect(Schema::hasTable('users'))->toBeFalse();
|
||||
|
||||
tenancy()->initialize($pendingTenant);
|
||||
|
||||
// MigrateDatabase includes/excludes pending tenants based on its $includePending property,
|
||||
// regardless of the tenancy.pending.include_in_queries config.
|
||||
expect(Schema::hasTable('users'))->toBe($migrateWithPending ?? $includeInQueries);
|
||||
})->with([
|
||||
'include pending in queries' => [true],
|
||||
'exclude pending from queries' => [false],
|
||||
])->with([
|
||||
'migrate with pending' => [true],
|
||||
'migrate without pending' => [false],
|
||||
'default to config' => [null],
|
||||
]);
|
||||
|
||||
test('pending tenant databases can be seeded using a job unless configured otherwise', function (bool $includeInQueries, ?bool $seedWithPending) {
|
||||
config([
|
||||
'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class],
|
||||
'tenancy.pending.include_in_queries' => $includeInQueries,
|
||||
'tenancy.seeder_parameters.--class' => TestSeeder::class,
|
||||
]);
|
||||
|
||||
MigrateDatabase::$includePending = true;
|
||||
SeedDatabase::$includePending = $seedWithPending;
|
||||
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||
Event::listen(TenantCreated::class, JobPipeline::make([
|
||||
CreateDatabase::class,
|
||||
MigrateDatabase::class,
|
||||
SeedDatabase::class,
|
||||
])->send(function (TenantCreated $event) {
|
||||
return $event->tenant;
|
||||
})->toListener());
|
||||
|
||||
$pendingTenant = Tenant::createPending();
|
||||
|
||||
tenancy()->initialize($pendingTenant);
|
||||
|
||||
// SeedDatabase includes/excludes pending tenants based on its $includePending property,
|
||||
// regardless of the tenancy.pending.include_in_queries config.
|
||||
expect(User::where('email', 'seeded@user')->exists())->toBe($seedWithPending ?? $includeInQueries);
|
||||
})->with([
|
||||
'include pending in queries' => [true],
|
||||
'exclude pending from queries' => [false],
|
||||
])->with([
|
||||
'seed with pending' => [true],
|
||||
'seed without pending' => [false],
|
||||
'default to config' => [null],
|
||||
]);
|
||||
|
||||
test('jobs that run before tenants get fully created recognize pending tenants', function () {
|
||||
config([
|
||||
'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class],
|
||||
]);
|
||||
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||
Event::listen(TenantCreated::class, JobPipeline::make([
|
||||
CreateDatabase::class,
|
||||
PendingTenantJob::class,
|
||||
])->send(function (TenantCreated $event) {
|
||||
return $event->tenant;
|
||||
})->toListener());
|
||||
|
||||
Tenant::createPending();
|
||||
|
||||
expect(app('tenant_is_pending'))->toBeTrue();
|
||||
});
|
||||
|
||||
class PendingTenantJob
|
||||
{
|
||||
public function __construct(
|
||||
public Tenant $tenant,
|
||||
) {}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
app()->instance('tenant_is_pending', $this->tenant->pending());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,13 +89,14 @@ test('tenant user can be impersonated on a tenant domain', function () {
|
|||
->assertSee('You are logged in as Joe');
|
||||
|
||||
expect(UserImpersonation::isImpersonating())->toBeTrue();
|
||||
expect(session('tenancy_impersonating'))->toBeTrue();
|
||||
expect(session('tenancy_impersonation_guard'))->toBe('web');
|
||||
expect($token->auth_guard)->toBe('web');
|
||||
|
||||
// Leave impersonation
|
||||
UserImpersonation::stopImpersonating();
|
||||
|
||||
expect(UserImpersonation::isImpersonating())->toBeFalse();
|
||||
expect(session('tenancy_impersonating'))->toBeNull();
|
||||
expect(session('tenancy_impersonation_guard'))->toBeNull();
|
||||
|
||||
// Assert can't access the tenant dashboard
|
||||
pest()->get('http://foo.localhost/dashboard')
|
||||
|
|
@ -135,19 +136,113 @@ test('tenant user can be impersonated on a tenant path', function () {
|
|||
->assertSee('You are logged in as Joe');
|
||||
|
||||
expect(UserImpersonation::isImpersonating())->toBeTrue();
|
||||
expect(session('tenancy_impersonating'))->toBeTrue();
|
||||
expect(session('tenancy_impersonation_guard'))->toBe('web');
|
||||
expect($token->auth_guard)->toBe('web');
|
||||
|
||||
// Leave impersonation
|
||||
UserImpersonation::stopImpersonating();
|
||||
|
||||
expect(UserImpersonation::isImpersonating())->toBeFalse();
|
||||
expect(session('tenancy_impersonating'))->toBeNull();
|
||||
expect(session('tenancy_impersonation_guard'))->toBeNull();
|
||||
|
||||
// Assert can't access the tenant dashboard
|
||||
pest()->get('/acme/dashboard')
|
||||
->assertRedirect('/login');
|
||||
});
|
||||
|
||||
test('stopImpersonating can keep the user authenticated', function () {
|
||||
makeLoginRoute();
|
||||
|
||||
Route::middleware(InitializeTenancyByPath::class)->prefix('/{tenant}')->group(getRoutes(false));
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'id' => 'acme',
|
||||
'tenancy_db_name' => 'db' . Str::random(16),
|
||||
]);
|
||||
|
||||
migrateTenants();
|
||||
|
||||
$user = $tenant->run(function () {
|
||||
return ImpersonationUser::create([
|
||||
'name' => 'Joe',
|
||||
'email' => 'joe@local',
|
||||
'password' => bcrypt('secret'),
|
||||
]);
|
||||
});
|
||||
|
||||
// Impersonate the user
|
||||
$token = tenancy()->impersonate($tenant, $user->id, '/acme/dashboard');
|
||||
|
||||
pest()->get('/acme/impersonate/' . $token->token)
|
||||
->assertRedirect('/acme/dashboard');
|
||||
|
||||
expect(UserImpersonation::isImpersonating())->toBeTrue();
|
||||
|
||||
// Stop impersonating without logging out
|
||||
UserImpersonation::stopImpersonating(false);
|
||||
|
||||
// The impersonation session key should be cleared
|
||||
expect(UserImpersonation::isImpersonating())->toBeFalse();
|
||||
expect(session('tenancy_impersonation_guard'))->toBeNull();
|
||||
|
||||
// The user should still be authenticated
|
||||
pest()->get('/acme/dashboard')
|
||||
->assertSuccessful()
|
||||
->assertSee('You are logged in as Joe');
|
||||
});
|
||||
|
||||
test('stopImpersonating logs out the user from the impersonation guard stored in session', function () {
|
||||
Route::middleware(InitializeTenancyByPath::class)->prefix('/{tenant}')->group(getRoutes(false));
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'id' => 'acme',
|
||||
'tenancy_db_name' => 'db' . Str::random(16),
|
||||
]);
|
||||
|
||||
migrateTenants();
|
||||
|
||||
$user = $tenant->run(function () {
|
||||
return ImpersonationUser::create([
|
||||
'name' => 'Joe',
|
||||
'email' => 'joe@local',
|
||||
'password' => bcrypt('secret'),
|
||||
]);
|
||||
});
|
||||
|
||||
// Impersonate the user
|
||||
$token = tenancy()->impersonate($tenant, $user->id, '/acme/dashboard');
|
||||
|
||||
pest()->get('/acme/impersonate/' . $token->token)
|
||||
->assertRedirect('/acme/dashboard');
|
||||
|
||||
expect(session('tenancy_impersonation_guard'))->toBe('web');
|
||||
|
||||
// Impersonation logged in the user using the current guard ('web')
|
||||
expect(auth('web')->check())->toBeTrue();
|
||||
|
||||
config(['auth.guards.test' => [
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
]]);
|
||||
|
||||
// Manually log the user in through the 'test' guard
|
||||
auth('test')->loginUsingId($user->id);
|
||||
|
||||
// Should log the user out from the guard used for impersonation ('web')
|
||||
UserImpersonation::stopImpersonating();
|
||||
|
||||
expect(auth('web')->check())->toBeFalse();
|
||||
expect(auth('test')->check())->toBeTrue();
|
||||
|
||||
expect(UserImpersonation::isImpersonating())->toBeFalse();
|
||||
|
||||
// tenancy_impersonation_guard isn't in the session anymore,
|
||||
// stopImpersonating should throw an exception instead of logging out
|
||||
expect(fn() => UserImpersonation::stopImpersonating())->toThrow(Exception::class);
|
||||
|
||||
expect(auth('test')->check())->toBeTrue();
|
||||
});
|
||||
|
||||
test('tokens have a limited ttl', function () {
|
||||
Route::middleware(InitializeTenancyByDomain::class)->group(getRoutes());
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue