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

Merge branch 'master' into url-bootstrapper

This commit is contained in:
lukinovec 2023-02-01 17:07:37 +01:00 committed by GitHub
commit c9232aeee3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 825 additions and 388 deletions

View file

@ -111,7 +111,6 @@ jobs:
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
with: with:
php-version: '8.2' php-version: '8.2'
extensions: imagick, swoole
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Install composer dependencies - name: Install composer dependencies
run: composer install run: composer install

View file

@ -31,14 +31,16 @@ class TenancyServiceProvider extends ServiceProvider
Jobs\CreateDatabase::class, Jobs\CreateDatabase::class,
Jobs\MigrateDatabase::class, Jobs\MigrateDatabase::class,
// Jobs\SeedDatabase::class, // Jobs\SeedDatabase::class,
Jobs\CreateStorageSymlinks::class,
// Jobs\CreateStorageSymlinks::class,
// Your own jobs to prepare the tenant. // Your own jobs to prepare the tenant.
// Provision API keys, create S3 buckets, anything you want! // Provision API keys, create S3 buckets, anything you want!
])->send(function (Events\TenantCreated $event) { ])->send(function (Events\TenantCreated $event) {
return $event->tenant; return $event->tenant;
})->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production. })->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production.
// Listeners\CreateTenantStorage::class,
], ],
Events\SavingTenant::class => [], Events\SavingTenant::class => [],
Events\TenantSaved::class => [], Events\TenantSaved::class => [],
@ -56,7 +58,7 @@ class TenancyServiceProvider extends ServiceProvider
Events\TenantDeleted::class => [ Events\TenantDeleted::class => [
JobPipeline::make([ JobPipeline::make([
Jobs\DeleteDatabase::class, Jobs\DeleteDatabase::class,
Jobs\RemoveStorageSymlinks::class, // Jobs\RemoveStorageSymlinks::class,
])->send(function (Events\TenantDeleted $event) { ])->send(function (Events\TenantDeleted $event) {
return $event->tenant; return $event->tenant;
})->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production. })->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production.
@ -176,7 +178,7 @@ class TenancyServiceProvider extends ServiceProvider
protected function makeTenancyMiddlewareHighestPriority() protected function makeTenancyMiddlewareHighestPriority()
{ {
// PreventAccessFromCentralDomains has even higher priority than the identification middleware // PreventAccessFromCentralDomains has even higher priority than the identification middleware
$tenancyMiddleware = array_merge([Middleware\PreventAccessFromCentralDomains::class], config('tenancy.identification.middleware')); $tenancyMiddleware = array_merge([Middleware\PreventAccessFromUnwantedDomains::class], config('tenancy.identification.middleware'));
foreach (array_reverse($tenancyMiddleware) as $middleware) { foreach (array_reverse($tenancyMiddleware) as $middleware) {
$this->app[\Illuminate\Contracts\Http\Kernel::class]->prependToMiddlewarePriority($middleware); $this->app[\Illuminate\Contracts\Http\Kernel::class]->prependToMiddlewarePriority($middleware);

View file

@ -103,6 +103,7 @@ return [
Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class, Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class,
Stancl\Tenancy\Bootstrappers\BatchTenancyBootstrapper::class, Stancl\Tenancy\Bootstrappers\BatchTenancyBootstrapper::class,
// Stancl\Tenancy\Bootstrappers\UrlTenancyBootstrapper::class, // Stancl\Tenancy\Bootstrappers\UrlTenancyBootstrapper::class,
// Stancl\Tenancy\Bootstrappers\SessionTenancyBootstrapper::class,
// Stancl\Tenancy\Bootstrappers\MailTenancyBootstrapper::class, // Queueing mail requires using QueueTenancyBootstrapper with $forceRefresh set to true // Stancl\Tenancy\Bootstrappers\MailTenancyBootstrapper::class, // Queueing mail requires using QueueTenancyBootstrapper with $forceRefresh set to true
// Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed // Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed
], ],
@ -283,7 +284,6 @@ return [
'features' => [ 'features' => [
// Stancl\Tenancy\Features\UserImpersonation::class, // Stancl\Tenancy\Features\UserImpersonation::class,
// Stancl\Tenancy\Features\TelescopeTags::class, // Stancl\Tenancy\Features\TelescopeTags::class,
// Stancl\Tenancy\Features\UniversalRoutes::class,
// Stancl\Tenancy\Features\TenantConfig::class, // https://tenancyforlaravel.com/docs/v3/features/tenant-config // Stancl\Tenancy\Features\TenantConfig::class, // https://tenancyforlaravel.com/docs/v3/features/tenant-config
// Stancl\Tenancy\Features\CrossDomainRedirect::class, // https://tenancyforlaravel.com/docs/v3/features/cross-domain-redirect // Stancl\Tenancy\Features\CrossDomainRedirect::class, // https://tenancyforlaravel.com/docs/v3/features/cross-domain-redirect
], ],

View file

@ -4,7 +4,7 @@ declare(strict_types=1);
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Middleware\PreventAccessFromCentralDomains; use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains;
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -21,7 +21,7 @@ use Stancl\Tenancy\Middleware\PreventAccessFromCentralDomains;
Route::middleware([ Route::middleware([
'web', 'web',
InitializeTenancyByDomain::class, InitializeTenancyByDomain::class,
PreventAccessFromCentralDomains::class, PreventAccessFromUnwantedDomains::class,
])->group(function () { ])->group(function () {
Route::get('/', function () { Route::get('/', function () {
return 'This is your multi-tenant application. The id of the current tenant is ' . tenant('id'); return 'This is your multi-tenant application. The id of the current tenant is ' . tenant('id');

View file

@ -17,20 +17,20 @@
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"ext-json": "*", "ext-json": "*",
"illuminate/support": "^9.0", "illuminate/support": "^9.38",
"spatie/ignition": "^1.4", "spatie/ignition": "^1.4",
"ramsey/uuid": "^4.0", "ramsey/uuid": "^4.0",
"stancl/jobpipeline": "^1.0", "stancl/jobpipeline": "^1.0",
"stancl/virtualcolumn": "^1.3" "stancl/virtualcolumn": "^1.3"
}, },
"require-dev": { "require-dev": {
"laravel/framework": "^9.0", "laravel/framework": "^9.38",
"orchestra/testbench": "^7.0", "orchestra/testbench": "^7.0",
"league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-aws-s3-v3": "^3.0",
"doctrine/dbal": "^2.10", "doctrine/dbal": "^2.10",
"spatie/valuestore": "^1.2.5", "spatie/valuestore": "^1.2.5",
"pestphp/pest": "^1.21", "pestphp/pest": "^1.21",
"nunomaduro/larastan": "^1.0", "nunomaduro/larastan": "^2.4",
"spatie/invade": "^1.1" "spatie/invade": "^1.1"
}, },
"autoload": { "autoload": {

View file

@ -23,6 +23,7 @@ parameters:
- src/Commands/ClearPendingTenants.php - src/Commands/ClearPendingTenants.php
- src/Database/Concerns/PendingScope.php - src/Database/Concerns/PendingScope.php
- src/Database/ParentModelScope.php - src/Database/ParentModelScope.php
- '#Call to an undefined method Illuminate\\Database\\Eloquent\\Builder\:\:withPending\(\)#'
- -
message: '#invalid type Laravel\\Telescope\\IncomingEntry#' message: '#invalid type Laravel\\Telescope\\IncomingEntry#'
paths: paths:
@ -47,9 +48,14 @@ parameters:
message: '#Trying to invoke Closure\|null but it might not be a callable#' message: '#Trying to invoke Closure\|null but it might not be a callable#'
paths: paths:
- src/Database/DatabaseConfig.php - src/Database/DatabaseConfig.php
-
message: '#Unable to resolve the template type (TMapWithKeysKey|TMapWithKeysValue) in call to method#'
paths:
- src/Concerns/DealsWithTenantSymlinks.php
- '#Method Stancl\\Tenancy\\Tenancy::cachedResolvers\(\) should return array#' - '#Method Stancl\\Tenancy\\Tenancy::cachedResolvers\(\) should return array#'
- '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$tenancy#' - '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$tenancy#'
- '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$resolver#' - '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$resolver#'
checkMissingIterableValueType: false checkMissingIterableValueType: false
checkGenericClassInNonGenericObjectType: false # later we may want to enable this
treatPhpDocTypesAsCertain: false treatPhpDocTypesAsCertain: false

View file

@ -23,6 +23,7 @@
</filter> </filter>
<php> <php>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="APP_KEY" value="base64:uYVmTs9lrQbXWfHgSSiG0VZMjc2KG/fBbjV1i1JDVos="/>
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_DRIVER" value="redis"/> <env name="CACHE_DRIVER" value="redis"/>
<env name="MAIL_DRIVER" value="array"/> <env name="MAIL_DRIVER" value="array"/>

View file

@ -25,7 +25,7 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper
/** @var TenantWithDatabase $tenant */ /** @var TenantWithDatabase $tenant */
// Better debugging, but breaks cached lookup in prod // Better debugging, but breaks cached lookup in prod
if (app()->environment('local')) { if (app()->environment('local') || app()->environment('testing')) { // todo@docs mention this change in v4 upgrade guide https://github.com/archtechx/tenancy/pull/945#issuecomment-1268206149
$database = $tenant->database()->getName(); $database = $tenant->database()->getName();
if (! $tenant->database()->manager()->databaseExists($database)) { if (! $tenant->database()->manager()->databaseExists($database)) {
throw new TenantDatabaseDoesNotExistException($database); throw new TenantDatabaseDoesNotExistException($database);

View file

@ -79,9 +79,9 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
$dispatcher->listen(JobFailed::class, $revertToPreviousState); // artisan('queue:work') which fails $dispatcher->listen(JobFailed::class, $revertToPreviousState); // artisan('queue:work') which fails
} }
protected static function initializeTenancyForQueue(string|int $tenantId): void protected static function initializeTenancyForQueue(string|int|null $tenantId): void
{ {
if (! $tenantId) { if ($tenantId === null) {
// The job is not tenant-aware // The job is not tenant-aware
if (tenancy()->initialized) { if (tenancy()->initialized) {
// Tenancy was initialized, so we revert back to the central context // Tenancy was initialized, so we revert back to the central context

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers;
use Illuminate\Config\Repository;
use Illuminate\Contracts\Container\Container;
use Illuminate\Session\DatabaseSessionHandler;
use Illuminate\Session\SessionManager;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
/**
* This resets the database connection used by the database session driver.
*
* It runs each time tenancy is initialized or ended.
* That way the session driver always uses the current DB connection.
*/
class SessionTenancyBootstrapper implements TenancyBootstrapper
{
public function __construct(
protected Repository $config,
protected Container $container,
protected SessionManager $session,
) {
}
public function bootstrap(Tenant $tenant): void
{
$this->resetDatabaseHandler();
}
public function revert(): void
{
// When ending tenancy, this runs *before* the DatabaseTenancyBootstrapper, so DB tenancy
// is still bootstrapped. For that reason, we have to explicitly use the central connection
$this->resetDatabaseHandler(config('tenancy.database.central_connection'));
}
protected function resetDatabaseHandler(string $defaultConnection = null): void
{
$sessionDrivers = $this->session->getDrivers();
if (isset($sessionDrivers['database'])) {
/** @var \Illuminate\Session\Store $databaseDriver */
$databaseDriver = $sessionDrivers['database'];
$databaseDriver->setHandler($this->createDatabaseHandler($defaultConnection));
}
}
protected function createDatabaseHandler(string $defaultConnection = null): DatabaseSessionHandler
{
// Typically returns null, so this falls back to the default DB connection
$connection = $this->config->get('session.connection') ?? $defaultConnection;
// Based on SessionManager::createDatabaseDriver
return new DatabaseSessionHandler(
$this->container->make('db')->connection($connection),
$this->config->get('session.table'),
$this->config->get('session.lifetime'),
$this->container,
);
}
}

View file

@ -7,9 +7,11 @@ namespace Stancl\Tenancy\Commands;
use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\Console\Migrations\MigrateCommand; use Illuminate\Database\Console\Migrations\MigrateCommand;
use Illuminate\Database\Migrations\Migrator; use Illuminate\Database\Migrations\Migrator;
use Illuminate\Database\QueryException;
use Stancl\Tenancy\Concerns\DealsWithMigrations; use Stancl\Tenancy\Concerns\DealsWithMigrations;
use Stancl\Tenancy\Concerns\ExtendsLaravelCommand; use Stancl\Tenancy\Concerns\ExtendsLaravelCommand;
use Stancl\Tenancy\Concerns\HasTenantOptions; use Stancl\Tenancy\Concerns\HasTenantOptions;
use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException;
use Stancl\Tenancy\Events\DatabaseMigrated; use Stancl\Tenancy\Events\DatabaseMigrated;
use Stancl\Tenancy\Events\MigratingDatabase; use Stancl\Tenancy\Events\MigratingDatabase;
@ -28,6 +30,8 @@ class Migrate extends MigrateCommand
{ {
parent::__construct($migrator, $dispatcher); parent::__construct($migrator, $dispatcher);
$this->addOption('skip-failing');
$this->specifyParameters(); $this->specifyParameters();
} }
@ -43,16 +47,23 @@ class Migrate extends MigrateCommand
return 1; return 1;
} }
tenancy()->runForMultiple($this->getTenants(), function ($tenant) { foreach ($this->getTenants() as $tenant) {
$this->components->info("Tenant: {$tenant->getTenantKey()}"); try {
$tenant->run(function ($tenant) {
$this->line("Tenant: {$tenant->getTenantKey()}");
event(new MigratingDatabase($tenant)); event(new MigratingDatabase($tenant));
// Migrate
parent::handle();
// Migrate event(new DatabaseMigrated($tenant));
parent::handle(); });
} catch (TenantDatabaseDoesNotExistException|QueryException $th) {
event(new DatabaseMigrated($tenant)); if (! $this->option('skip-failing')) {
}); throw $th;
}
}
}
return 0; return 0;
} }

View file

@ -1,72 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Concerns;
use Closure;
use Stancl\Tenancy\Enums\LogMode;
use Stancl\Tenancy\Events\Contracts\TenancyEvent;
use Stancl\Tenancy\Tenancy;
// todo finish this feature
/**
* @mixin Tenancy
*/
trait Debuggable
{
protected LogMode $logMode = LogMode::NONE;
protected array $eventLog = [];
public function log(LogMode $mode = LogMode::SILENT): static
{
$this->eventLog = [];
$this->logMode = $mode;
return $this;
}
public function logMode(): LogMode
{
return $this->logMode;
}
public function getLog(): array
{
return $this->eventLog;
}
public function logEvent(TenancyEvent $event): static
{
$this->eventLog[] = ['time' => now(), 'event' => $event::class, 'tenant' => $this->tenant];
return $this;
}
public function dump(Closure $dump = null): static
{
$dump ??= dd(...);
// Dump the log if we were already logging in silent mode
// Otherwise start logging in instant mode
match ($this->logMode) {
LogMode::NONE => $this->log(LogMode::INSTANT),
LogMode::SILENT => $dump($this->eventLog),
LogMode::INSTANT => null,
};
return $this;
}
public function dd(Closure $dump = null): void
{
$dump ??= dd(...);
if ($this->logMode === LogMode::SILENT) {
$dump($this->eventLog);
} else {
$dump($this);
}
}
}

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns; namespace Stancl\Tenancy\Database\Concerns;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Database\TenantScope; use Stancl\Tenancy\Database\TenantScope;
use Stancl\Tenancy\Tenancy; use Stancl\Tenancy\Tenancy;
@ -13,7 +14,7 @@ use Stancl\Tenancy\Tenancy;
*/ */
trait BelongsToTenant trait BelongsToTenant
{ {
public function tenant() public function tenant(): BelongsTo
{ {
return $this->belongsTo(config('tenancy.models.tenant'), Tenancy::tenantKeyColumn()); return $this->belongsTo(config('tenancy.models.tenant'), Tenancy::tenantKeyColumn());
} }

View file

@ -87,7 +87,7 @@ class DatabaseConfig
{ {
$this->tenant->setInternal('db_name', $this->getName()); $this->tenant->setInternal('db_name', $this->getName());
if ($this->connectionDriverManager($this->getTemplateConnectionName()) instanceof Contracts\ManagesDatabaseUsers) { if ($this->connectionDriverManager($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) {
$this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($this->tenant)); $this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($this->tenant));
$this->tenant->setInternal('db_password', $this->getPassword() ?? (static::$passwordGenerator)($this->tenant)); $this->tenant->setInternal('db_password', $this->getPassword() ?? (static::$passwordGenerator)($this->tenant));
} }
@ -97,11 +97,29 @@ class DatabaseConfig
} }
} }
public function getTemplateConnectionName(): string public function getTemplateConnectionDriver(): string
{ {
return $this->tenant->getInternal('db_connection') return $this->getTemplateConnection()['driver'];
?? config('tenancy.database.template_tenant_connection') }
?? config('tenancy.database.central_connection');
public function getTemplateConnection(): array
{
if ($template = $this->tenant->getInternal('db_connection')) {
return config("database.connections.{$template}");
}
if ($template = config('tenancy.database.template_tenant_connection')) {
return is_array($template) ? array_merge($this->getCentralConnection(), $template) : config("database.connections.{$template}");
}
return $this->getCentralConnection();
}
protected function getCentralConnection(): array
{
$centralConnectionName = config('tenancy.database.central_connection');
return config("database.connections.{$centralConnectionName}");
} }
public function getTenantHostConnectionName(): string public function getTenantHostConnectionName(): string
@ -114,8 +132,7 @@ class DatabaseConfig
*/ */
public function connection(): array public function connection(): array
{ {
$template = $this->getTemplateConnectionName(); $templateConnection = $this->getTemplateConnection();
$templateConnection = config("database.connections.{$template}");
return $this->manager()->makeConnectionConfig( return $this->manager()->makeConnectionConfig(
array_merge($templateConnection, $this->tenantConfig()), array_merge($templateConnection, $this->tenantConfig()),
@ -129,10 +146,9 @@ class DatabaseConfig
public function hostConnection(): array public function hostConnection(): array
{ {
$config = $this->tenantConfig(); $config = $this->tenantConfig();
$template = $this->getTemplateConnectionName(); $templateConnection = $this->getTemplateConnection();
$templateConnection = config("database.connections.{$template}");
if ($this->connectionDriverManager($template) instanceof Contracts\ManagesDatabaseUsers) { if ($this->connectionDriverManager($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) {
// We're removing the username and password because user with these credentials is not created yet // We're removing the username and password because user with these credentials is not created yet
// If you need to provide username and password when using PermissionControlledMySQLDatabaseManager, // If you need to provide username and password when using PermissionControlledMySQLDatabaseManager,
// consider creating a new connection and use it as `tenancy_db_connection` tenant config key // consider creating a new connection and use it as `tenancy_db_connection` tenant config key
@ -196,7 +212,7 @@ class DatabaseConfig
$tenantHostConnectionName = $this->getTenantHostConnectionName(); $tenantHostConnectionName = $this->getTenantHostConnectionName();
config(["database.connections.{$tenantHostConnectionName}" => $this->hostConnection()]); config(["database.connections.{$tenantHostConnectionName}" => $this->hostConnection()]);
$manager = $this->connectionDriverManager($tenantHostConnectionName); $manager = $this->connectionDriverManager(config("database.connections.{$tenantHostConnectionName}.driver"));
if ($manager instanceof Contracts\StatefulTenantDatabaseManager) { if ($manager instanceof Contracts\StatefulTenantDatabaseManager) {
$manager->setConnection($tenantHostConnectionName); $manager->setConnection($tenantHostConnectionName);
@ -211,10 +227,8 @@ class DatabaseConfig
* *
* @throws DatabaseManagerNotRegisteredException * @throws DatabaseManagerNotRegisteredException
*/ */
protected function connectionDriverManager(string $connectionName): Contracts\TenantDatabaseManager protected function connectionDriverManager(string $driver): Contracts\TenantDatabaseManager
{ {
$driver = config("database.connections.{$connectionName}.driver");
$databaseManagers = config('tenancy.database.managers'); $databaseManagers = config('tenancy.database.managers');
if (! array_key_exists($driver, $databaseManagers)) { if (! array_key_exists($driver, $databaseManagers)) {

View file

@ -10,6 +10,7 @@ use Stancl\Tenancy\Contracts;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Database\Concerns; use Stancl\Tenancy\Database\Concerns;
use Stancl\Tenancy\Events; use Stancl\Tenancy\Events;
use Stancl\Tenancy\Tenancy;
/** /**
* @property string $domain * @property string $domain
@ -28,7 +29,7 @@ class Domain extends Model implements Contracts\Domain
public function tenant(): BelongsTo public function tenant(): BelongsTo
{ {
return $this->belongsTo(config('tenancy.models.tenant')); return $this->belongsTo(config('tenancy.models.tenant'), Tenancy::tenantKeyColumn());
} }
protected $dispatchesEvents = [ protected $dispatchesEvents = [

View file

@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Enums;
enum LogMode
{
case NONE;
case SILENT;
case INSTANT;
}

View file

@ -1,64 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Features;
use Closure;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Route as Router;
use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Middleware;
use Stancl\Tenancy\Tenancy;
class UniversalRoutes implements Feature
{
public static string $middlewareGroup = 'universal';
// todo docblock
/** @var array<class-string<\Stancl\Tenancy\Middleware\IdentificationMiddleware>> */
public static array $identificationMiddlewares = [
Middleware\InitializeTenancyByDomain::class,
Middleware\InitializeTenancyBySubdomain::class,
];
public function bootstrap(Tenancy $tenancy): void
{
foreach (static::$identificationMiddlewares as $middleware) {
$originalOnFail = $middleware::$onFail;
$middleware::$onFail = function ($exception, $request, $next) use ($originalOnFail) {
if (static::routeHasMiddleware($request->route(), static::$middlewareGroup)) {
return $next($request);
}
if ($originalOnFail) {
return $originalOnFail($exception, $request, $next);
}
throw $exception;
};
}
}
public static function routeHasMiddleware(Route $route, string $middleware): bool
{
/** @var array $routeMiddleware */
$routeMiddleware = $route->middleware();
if (in_array($middleware, $routeMiddleware, true)) {
return true;
}
// Loop one level deep and check if the route's middleware
// groups have the searched middleware group inside them
$middlewareGroups = Router::getMiddlewareGroups();
foreach ($route->gatherMiddleware() as $inner) {
if (! $inner instanceof Closure && isset($middlewareGroups[$inner]) && in_array($middleware, $middlewareGroups[$inner], true)) {
return true;
}
}
return false;
}
}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Listeners;
use Stancl\Tenancy\Events\TenantCreated;
class CreateTenantStorage
{
public function handle(TenantCreated $event): void
{
$storage_path = $event->tenant->run(fn () => storage_path());
mkdir("$storage_path", 0777, true); // Create the tenant's folder inside storage/
mkdir("$storage_path/framework/cache", 0777, true); // Create /framework/cache inside the tenant's storage (used for e.g. real-time facades)
}
}

View file

@ -11,6 +11,9 @@ class DeleteTenantStorage
{ {
public function handle(DeletingTenant $event): void public function handle(DeletingTenant $event): void
{ {
// todo@lukas since this is using the 'File' facade instead of low-level PHP functions, Tenancy might affect this?
// Therefore, when Tenancy is initialized, this might look INSIDE the tenant's storage, instead of the main storage dir?
// The DeletingTenant event will be fired in the central context in 99% of cases, but sometimes it might run in the tenant context (from another tenant) so we want to make sure this works well in all contexts.
File::deleteDirectory($event->tenant->run(fn () => storage_path())); File::deleteDirectory($event->tenant->run(fn () => storage_path()));
} }
} }

View file

@ -22,6 +22,11 @@ class InitializeTenancyByDomain extends IdentificationMiddleware
/** @return \Illuminate\Http\Response|mixed */ /** @return \Illuminate\Http\Response|mixed */
public function handle(Request $request, Closure $next): mixed public function handle(Request $request, Closure $next): mixed
{ {
if (in_array($request->getHost(), config('tenancy.central_domains', []), true)) {
// Always bypass tenancy initialization when host is in central domains
return $next($request);
}
return $this->initializeTenancy( return $this->initializeTenancy(
$request, $request,
$next, $next,

View file

@ -28,14 +28,13 @@ class InitializeTenancyByPath extends IdentificationMiddleware
/** @return \Illuminate\Http\Response|mixed */ /** @return \Illuminate\Http\Response|mixed */
public function handle(Request $request, Closure $next): mixed public function handle(Request $request, Closure $next): mixed
{ {
/** @var Route $route */ $route = $this->route($request);
$route = $request->route();
// Only initialize tenancy if tenant is the first parameter // Only initialize tenancy if tenant is the first parameter
// We don't want to initialize tenancy if the tenant is // We don't want to initialize tenancy if the tenant is
// simply injected into some route controller action. // simply injected into some route controller action.
if ($route->parameterNames()[0] === PathTenantResolver::tenantParameterName()) { if ($route->parameterNames()[0] === PathTenantResolver::tenantParameterName()) {
$this->setDefaultTenantForRouteParametersWhenTenancyIsInitialized(); $this->setDefaultTenantForRouteParametersWhenInitializingTenancy();
return $this->initializeTenancy( return $this->initializeTenancy(
$request, $request,
@ -47,7 +46,26 @@ class InitializeTenancyByPath extends IdentificationMiddleware
} }
} }
protected function setDefaultTenantForRouteParametersWhenTenancyIsInitialized(): void protected function route(Request $request): Route
{
/** @var ?Route $route */
$route = $request->route();
if (! $route) {
// Create a fake $route instance that has enough information for this middleware's needs
$route = new Route($request->method(), $request->getUri(), []);
/**
* getPathInfo() returns the path except the root domain.
* We fetch the first parameter because tenant parameter is *always* first.
*/
$route->parameters[PathTenantResolver::tenantParameterName()] = explode('/', ltrim($request->getPathInfo(), '/'))[0];
$route->parameterNames[] = PathTenantResolver::tenantParameterName();
}
return $route;
}
protected function setDefaultTenantForRouteParametersWhenInitializingTenancy(): void
{ {
Event::listen(InitializingTenancy::class, function (InitializingTenancy $event) { Event::listen(InitializingTenancy::class, function (InitializingTenancy $event) {
/** @var Tenant $tenant */ /** @var Tenant $tenant */

View file

@ -27,6 +27,11 @@ class InitializeTenancyBySubdomain extends InitializeTenancyByDomain
/** @return Response|mixed */ /** @return Response|mixed */
public function handle(Request $request, Closure $next): mixed public function handle(Request $request, Closure $next): mixed
{ {
if (in_array($request->getHost(), config('tenancy.central_domains', []), true)) {
// Always bypass tenancy initialization when host is in central domains
return $next($request);
}
$subdomain = $this->makeSubdomain($request->getHost()); $subdomain = $this->makeSubdomain($request->getHost());
if (is_object($subdomain) && $subdomain instanceof Exception) { if (is_object($subdomain) && $subdomain instanceof Exception) {

View file

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Middleware;
use Closure;
use Illuminate\Http\Request;
class PreventAccessFromCentralDomains
{
/**
* Set this property if you want to customize the on-fail behavior.
*/
public static ?Closure $abortRequest;
/** @return \Illuminate\Http\Response|mixed */
public function handle(Request $request, Closure $next): mixed
{
if (in_array($request->getHost(), config('tenancy.central_domains'))) {
$abortRequest = static::$abortRequest ?? function () {
abort(404);
};
return $abortRequest($request, $next);
}
return $next($request);
}
}

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Route as Router;
// todo come up with a better name
class PreventAccessFromUnwantedDomains
{
/**
* Set this property if you want to customize the on-fail behavior.
*/
public static ?Closure $abortRequest;
/** @return \Illuminate\Http\Response|mixed */
public function handle(Request $request, Closure $next): mixed
{
/** @var Route $route */
$route = $request->route();
if ($this->routeHasMiddleware($route, 'universal')) {
return $next($request);
}
if (in_array($request->getHost(), config('tenancy.central_domains'), true)) {
$abortRequest = static::$abortRequest ?? function () {
abort(404);
};
return $abortRequest($request, $next);
}
return $next($request);
}
protected function routeHasMiddleware(Route $route, string $middleware): bool
{
/** @var array $routeMiddleware */
$routeMiddleware = $route->middleware();
if (in_array($middleware, $routeMiddleware, true)) {
return true;
}
// Loop one level deep and check if the route's middleware
// groups have the searched middleware group inside them
$middlewareGroups = Router::getMiddlewareGroups();
foreach ($route->gatherMiddleware() as $inner) {
if (! $inner instanceof Closure && isset($middlewareGroups[$inner]) && in_array($middleware, $middlewareGroups[$inner], true)) {
return true;
}
}
return false;
}
}

View file

@ -27,9 +27,7 @@ abstract class CachedTenantResolver implements TenantResolver
$key = $this->getCacheKey(...$args); $key = $this->getCacheKey(...$args);
if ($this->cache->has($key)) { if ($tenant = $this->cache->get($key)) {
$tenant = $this->cache->get($key);
$this->resolved($tenant, ...$args); $this->resolved($tenant, ...$args);
return $tenant; return $tenant;

View file

@ -8,21 +8,18 @@ use Closure;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Traits\Macroable; use Illuminate\Support\Traits\Macroable;
use Stancl\Tenancy\Concerns\Debuggable;
use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByIdException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByIdException;
class Tenancy class Tenancy
{ {
use Macroable, Debuggable; use Macroable;
/** /**
* The current tenant. * The current tenant.
*
* @var (Tenant&Model)|null
*/ */
public ?Tenant $tenant = null; public Tenant|null $tenant = null;
// todo docblock // todo docblock
public ?Closure $getBootstrappersUsing = null; public ?Closure $getBootstrappersUsing = null;
@ -97,9 +94,9 @@ class Tenancy
public static function model(): Tenant&Model public static function model(): Tenant&Model
{ {
/** @var class-string<Tenant&Model> $class */
$class = config('tenancy.models.tenant'); $class = config('tenancy.models.tenant');
/** @var Tenant&Model $model */
$model = new $class; $model = new $class;
return $model; return $model;
@ -113,12 +110,9 @@ class Tenancy
/** /**
* Try to find a tenant using an ID. * Try to find a tenant using an ID.
*
* @return (Tenant&Model)|null
*/ */
public static function find(int|string $id): Tenant|null public static function find(int|string $id): Tenant|null
{ {
// todo update all syntax like this once we're fully on PHP 8.2
/** @var (Tenant&Model)|null */ /** @var (Tenant&Model)|null */
$tenant = static::model()->where(static::model()->getTenantKeyName(), $id)->first(); $tenant = static::model()->where(static::model()->getTenantKeyName(), $id)->first();

View file

@ -6,13 +6,10 @@ namespace Stancl\Tenancy;
use Illuminate\Cache\CacheManager; use Illuminate\Cache\CacheManager;
use Illuminate\Database\Console\Migrations\FreshCommand; use Illuminate\Database\Console\Migrations\FreshCommand;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use Stancl\Tenancy\Contracts\Domain; use Stancl\Tenancy\Contracts\Domain;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Enums\LogMode;
use Stancl\Tenancy\Events\Contracts\TenancyEvent;
use Stancl\Tenancy\Resolvers\DomainTenantResolver; use Stancl\Tenancy\Resolvers\DomainTenantResolver;
class TenancyServiceProvider extends ServiceProvider class TenancyServiceProvider extends ServiceProvider
@ -121,18 +118,6 @@ class TenancyServiceProvider extends ServiceProvider
$this->loadRoutesFrom(__DIR__ . '/../assets/routes.php'); $this->loadRoutesFrom(__DIR__ . '/../assets/routes.php');
} }
Event::listen('Stancl\\Tenancy\\Events\\*', function (string $name, array $data) {
$event = $data[0];
if ($event instanceof TenancyEvent) {
match (tenancy()->logMode()) {
LogMode::SILENT => tenancy()->logEvent($event),
LogMode::INSTANT => dump($event), // todo1 perhaps still log
default => null,
};
}
});
$this->app->singleton('globalUrl', function ($app) { $this->app->singleton('globalUrl', function ($app) {
if ($app->bound(FilesystemTenancyBootstrapper::class)) { if ($app->bound(FilesystemTenancyBootstrapper::class)) {
$instance = clone $app['url']; $instance = clone $app['url'];

View file

@ -50,6 +50,8 @@ test('context is switched when tenancy is reinitialized', function () {
}); });
test('central helper runs callbacks in the central state', function () { test('central helper runs callbacks in the central state', function () {
withTenantDatabases();
tenancy()->initialize($tenant = Tenant::create()); tenancy()->initialize($tenant = Tenant::create());
tenancy()->central(function () { tenancy()->central(function () {
@ -60,6 +62,8 @@ test('central helper runs callbacks in the central state', function () {
}); });
test('central helper returns the value from the callback', function () { test('central helper returns the value from the callback', function () {
withTenantDatabases();
tenancy()->initialize(Tenant::create()); tenancy()->initialize(Tenant::create());
pest()->assertSame('foo', tenancy()->central(function () { pest()->assertSame('foo', tenancy()->central(function () {
@ -68,6 +72,8 @@ test('central helper returns the value from the callback', function () {
}); });
test('central helper reverts back to tenant context', function () { test('central helper reverts back to tenant context', function () {
withTenantDatabases();
tenancy()->initialize($tenant = Tenant::create()); tenancy()->initialize($tenant = Tenant::create());
tenancy()->central(function () { tenancy()->central(function () {

View file

@ -23,6 +23,8 @@ beforeEach(function () {
}); });
test('batch repository is set to tenant connection and reverted', function () { test('batch repository is set to tenant connection and reverted', function () {
withTenantDatabases();
$tenant = Tenant::create(); $tenant = Tenant::create();
$tenant2 = Tenant::create(); $tenant2 = Tenant::create();

View file

@ -18,11 +18,13 @@ use Stancl\Tenancy\Events\TenantCreated;
use Stancl\Tenancy\Events\TenantDeleted; use Stancl\Tenancy\Events\TenantDeleted;
use Stancl\Tenancy\Tests\Etc\TestSeeder; use Stancl\Tenancy\Tests\Etc\TestSeeder;
use Stancl\Tenancy\Events\DeletingTenant; use Stancl\Tenancy\Events\DeletingTenant;
use Stancl\Tenancy\Events\DatabaseMigrated;
use Stancl\Tenancy\Tests\Etc\ExampleSeeder; use Stancl\Tenancy\Tests\Etc\ExampleSeeder;
use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException;
beforeEach(function () { beforeEach(function () {
if (file_exists($schemaPath = 'tests/Etc/tenant-schema-test.dump')) { if (file_exists($schemaPath = 'tests/Etc/tenant-schema-test.dump')) {
@ -109,6 +111,46 @@ test('migrate command loads schema state', function () {
expect(Schema::hasTable('users'))->toBeTrue(); expect(Schema::hasTable('users'))->toBeTrue();
}); });
test('migrate command only throws exceptions if skip-failing is not passed', function() {
Tenant::create();
$tenantWithoutDatabase = Tenant::create();
$databaseToDrop = $tenantWithoutDatabase->run(fn() => DB::connection()->getDatabaseName());
DB::statement("DROP DATABASE `$databaseToDrop`");
Tenant::create();
expect(fn() => pest()->artisan('tenants:migrate --schema-path="tests/Etc/tenant-schema.dump"'))->toThrow(TenantDatabaseDoesNotExistException::class);
expect(fn() => pest()->artisan('tenants:migrate --schema-path="tests/Etc/tenant-schema.dump" --skip-failing'))->not()->toThrow(TenantDatabaseDoesNotExistException::class);
});
test('migrate command does not stop after the first failure if skip-failing is passed', function() {
$tenants = collect([
Tenant::create(),
$tenantWithoutDatabase = Tenant::create(),
Tenant::create(),
]);
$migratedTenants = 0;
Event::listen(DatabaseMigrated::class, function() use (&$migratedTenants) {
$migratedTenants++;
});
$databaseToDrop = $tenantWithoutDatabase->run(fn() => DB::connection()->getDatabaseName());
DB::statement("DROP DATABASE `$databaseToDrop`");
Artisan::call('tenants:migrate', [
'--schema-path' => '"tests/Etc/tenant-schema.dump"',
'--skip-failing' => true,
'--tenants' => $tenants->pluck('id')->toArray(),
]);
expect($migratedTenants)->toBe(2);
});
test('dump command works', function () { test('dump command works', function () {
$tenant = Tenant::create(); $tenant = Tenant::create();
$schemaPath = 'tests/Etc/tenant-schema-test.dump'; $schemaPath = 'tests/Etc/tenant-schema-test.dump';
@ -180,17 +222,17 @@ test('rollback command works', function () {
expect(Schema::hasTable('users'))->toBeFalse(); expect(Schema::hasTable('users'))->toBeFalse();
}); });
test('seed command works', function (){ test('seed command works', function () {
$tenant = Tenant::create(); $tenant = Tenant::create();
Artisan::call('tenants:migrate'); Artisan::call('tenants:migrate');
$tenant->run(function (){ $tenant->run(function () {
expect(DB::table('users')->count())->toBe(0); expect(DB::table('users')->count())->toBe(0);
}); });
Artisan::call('tenants:seed', ['--class' => TestSeeder::class]); Artisan::call('tenants:seed', ['--class' => TestSeeder::class]);
$tenant->run(function (){ $tenant->run(function () {
$user = DB::table('users'); $user = DB::table('users');
expect($user->count())->toBe(1) expect($user->count())->toBe(1)
->and($user->first()->email)->toBe('seeded@user'); ->and($user->first()->email)->toBe('seeded@user');

View file

@ -1,68 +0,0 @@
<?php
use Stancl\Tenancy\Enums\LogMode;
use Stancl\Tenancy\Events\EndingTenancy;
use Stancl\Tenancy\Events\InitializingTenancy;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Tests\Etc\Tenant;
test('tenancy can log events silently', function () {
tenancy()->log(LogMode::SILENT);
$tenant = Tenant::create();
tenancy()->initialize($tenant);
tenancy()->end();
assertTenancyInitializedAndEnded(tenancy()->getLog(), $tenant);
});
test('tenancy logs event silently by default', function () {
tenancy()->log();
expect(tenancy()->logMode())->toBe(LogMode::SILENT);
});
test('the log can be dumped', function (string $method) {
tenancy()->log();
$tenant = Tenant::create();
tenancy()->initialize($tenant);
tenancy()->end();
$output = [];
tenancy()->$method(function ($data) use (&$output) {
$output = $data;
});
assertTenancyInitializedAndEnded($output, $tenant);
})->with([
'dump',
'dd',
]);
test('tenancy can log events immediately', function () {
// todo implement
pest()->markTestIncomplete();
});
// todo test the different behavior of the methods in different contexts, or get rid of the logic and simplify it
function assertTenancyInitializedAndEnded(array $log, Tenant $tenant): void
{
expect($log)->toHaveCount(4);
expect($log[0]['event'])->toBe(InitializingTenancy::class);
expect($log[0]['tenant'])->toBe($tenant);
expect($log[1]['event'])->toBe(TenancyInitialized::class);
expect($log[1]['tenant'])->toBe($tenant);
expect($log[2]['event'])->toBe(EndingTenancy::class);
expect($log[2]['tenant'])->toBe($tenant);
expect($log[3]['event'])->toBe(TenancyEnded::class);
expect($log[3]['tenant'])->toBe($tenant);
}

View file

@ -9,7 +9,7 @@ beforeEach(function () {
config(['tenancy.models.tenant' => DatabaseAndDomainTenant::class]); config(['tenancy.models.tenant' => DatabaseAndDomainTenant::class]);
}); });
test('job delete domains successfully', function (){ test('job delete domains successfully', function () {
$tenant = DatabaseAndDomainTenant::create(); $tenant = DatabaseAndDomainTenant::create();
$tenant->domains()->create([ $tenant->domains()->create([

View file

@ -8,7 +8,6 @@ use Stancl\Tenancy\Database\Models;
use Stancl\Tenancy\Database\Models\Domain; use Stancl\Tenancy\Database\Models\Domain;
use Stancl\Tenancy\Exceptions\DomainOccupiedByOtherTenantException; use Stancl\Tenancy\Exceptions\DomainOccupiedByOtherTenantException;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
use Stancl\Tenancy\Features\UniversalRoutes;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Resolvers\DomainTenantResolver; use Stancl\Tenancy\Resolvers\DomainTenantResolver;
@ -95,7 +94,6 @@ test('throw correct exception when onFail is null and universal routes are enabl
// Enable UniversalRoute feature // Enable UniversalRoute feature
Route::middlewareGroup('universal', []); Route::middlewareGroup('universal', []);
config(['tenancy.features' => [UniversalRoutes::class]]);
$this->withoutExceptionHandling()->get('http://foo.localhost/foo/abc/xyz'); $this->withoutExceptionHandling()->get('http://foo.localhost/foo/abc/xyz');
})->throws(TenantCouldNotBeIdentifiedOnDomainException::class);; })->throws(TenantCouldNotBeIdentifiedOnDomainException::class);;

View file

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain;
use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains;
use Stancl\Tenancy\Tests\Etc\EarlyIdentification\Controller;
use Stancl\Tenancy\Tests\Etc\Tenant;
beforeEach(function () {
config()->set([
'tenancy.token' => 'central-abc123',
]);
Event::listen(TenancyInitialized::class, function (TenancyInitialized $event) {
config()->set([
'tenancy.token' => $event->tenancy->tenant->getTenantKey() . '-abc123',
]);
});
});
test('early identification works with path identification', function () {
app(Kernel::class)->pushMiddleware(InitializeTenancyByPath::class);
Route::group([
'prefix' => '/{tenant}',
], function () {
Route::get('/foo', [Controller::class, 'index'])->name('foo');
});
Tenant::create([
'id' => 'acme',
]);
$response = pest()->get('/acme/foo')->assertOk();
assertTenancyInitializedInEarlyIdentificationRequest($response->getContent());
// check if default parameter feature is working fine by asserting that the route WITHOUT the tenant parameter
// matches the route WITH the tenant parameter
expect(route('foo'))->toBe(route('foo', ['tenant' => 'acme']));
});
test('early identification works with request data identification', function (string $type) {
app(Kernel::class)->pushMiddleware(InitializeTenancyByRequestData::class);
Route::get('/foo', [Controller::class, 'index'])->name('foo');
$tenant = Tenant::create([
'id' => 'acme',
]);
if ($type === 'header') {
$response = pest()->get('/foo', ['X-Tenant' => $tenant->id])->assertOk();
} elseif ($type === 'queryParameter') {
$response = pest()->get("/foo?tenant=$tenant->id")->assertOk();
}
assertTenancyInitializedInEarlyIdentificationRequest($response->getContent());
})->with([
'using request header parameter' => 'header',
'using request query parameter' => 'queryParameter'
]);
// The name of this test is suffixed by the dataset — domain / subdomain / domainOrSubdomain identification
test('early identification works', function (string $middleware, string $domain, string $url) {
app(Kernel::class)->pushMiddleware($middleware);
config(['tenancy.tenant_model' => Tenant::class]);
Route::get('/foo', [Controller::class, 'index'])
->middleware(PreventAccessFromUnwantedDomains::class)
->name('foo');
$tenant = Tenant::create();
$tenant->domains()->create([
'domain' => $domain,
]);
$response = pest()->get($url)->assertOk();
assertTenancyInitializedInEarlyIdentificationRequest($response->getContent());
})->with([
'domain identification' => ['middleware' => InitializeTenancyByDomain::class, 'domain' => 'foo.test', 'url' => 'http://foo.test/foo'],
'subdomain identification' => ['middleware' => InitializeTenancyBySubdomain::class, 'domain' => 'foo', 'url' => 'http://foo.localhost/foo'],
'domainOrSubdomain identification using domain' => ['middleware' => InitializeTenancyByDomainOrSubdomain::class, 'domain' => 'foo.test', 'url' => 'http://foo.test/foo'],
'domainOrSubdomain identification using subdomain' => ['middleware' => InitializeTenancyByDomainOrSubdomain::class, 'domain' => 'foo', 'url' => 'http://foo.localhost/foo'],
]);
function assertTenancyInitializedInEarlyIdentificationRequest(string|false $string): void
{
expect($string)->toBe(tenant()->getTenantKey() . '-abc123'); // Assert that the service class returns tenant value
expect(app()->make('additionalMiddlewareRunsInTenantContext'))->toBeTrue(); // Assert that middleware added in the controller constructor runs in tenant context
expect(app()->make('controllerRunsInTenantContext'))->toBeTrue(); // Assert that tenancy is initialized in the controller constructor
}

View file

@ -0,0 +1,16 @@
<?php
namespace Stancl\Tenancy\Tests\Etc\EarlyIdentification;
use Closure;
use Illuminate\Http\Request;
class AdditionalMiddleware
{
public function handle(Request $request, Closure $next): mixed
{
app()->instance('additionalMiddlewareRunsInTenantContext', tenancy()->initialized);
return $next($request);
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace Stancl\Tenancy\Tests\Etc\EarlyIdentification;
use Illuminate\Routing\Controller as BaseController;
class Controller extends BaseController
{
public function __construct(public Service $service)
{
app()->instance('controllerRunsInTenantContext', tenancy()->initialized);
$this->middleware(AdditionalMiddleware::class);
}
public function index(): string
{
return $this->service->token;
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Tests\Etc\EarlyIdentification;
class Service
{
public string $token;
public function __construct()
{
$this->token = config('tenancy.token');
}
}

View file

@ -30,7 +30,7 @@ class HttpKernel extends Kernel
*/ */
protected $middlewareGroups = [ protected $middlewareGroups = [
'web' => [ 'web' => [
\Orchestra\Testbench\Http\Middleware\EncryptCookies::class, \Illuminate\Cookie\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class, \Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class, // \Illuminate\Session\Middleware\AuthenticateSession::class,

View file

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateSessionsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->text('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('sessions');
}
}

View file

@ -27,6 +27,8 @@ function assertMailerTransportUsesPassword(string|null $password) {
}; };
test('mailer transport uses the correct credentials', function() { test('mailer transport uses the correct credentials', function() {
withTenantDatabases();
config(['mail.default' => 'smtp', 'mail.mailers.smtp.password' => $defaultPassword = 'DEFAULT']); config(['mail.default' => 'smtp', 'mail.mailers.smtp.password' => $defaultPassword = 'DEFAULT']);
MailTenancyBootstrapper::$credentialsMap = ['mail.mailers.smtp.password' => 'smtp_password']; MailTenancyBootstrapper::$credentialsMap = ['mail.mailers.smtp.password' => 'smtp_password'];
@ -52,6 +54,8 @@ test('mailer transport uses the correct credentials', function() {
test('initializing and ending tenancy binds a fresh MailManager instance without cached mailers', function() { test('initializing and ending tenancy binds a fresh MailManager instance without cached mailers', function() {
withTenantDatabases();
$mailers = fn() => invade(app(MailManager::class))->mailers; $mailers = fn() => invade(app(MailManager::class))->mailers;
app(MailManager::class)->mailer('smtp'); app(MailManager::class)->mailer('smtp');

View file

@ -1,6 +1,10 @@
<?php <?php
use Stancl\Tenancy\Tests\TestCase; use Stancl\Tenancy\Tests\TestCase;
use Stancl\JobPipeline\JobPipeline;
use Illuminate\Support\Facades\Event;
use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Events\TenantCreated;
uses(TestCase::class)->in(__DIR__); uses(TestCase::class)->in(__DIR__);
@ -8,3 +12,10 @@ function pest(): TestCase
{ {
return Pest\TestSuite::getInstance()->test; return Pest\TestSuite::getInstance()->test;
} }
function withTenantDatabases()
{
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
}

View file

@ -3,23 +3,23 @@
declare(strict_types=1); declare(strict_types=1);
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Spatie\Valuestore\Valuestore; use Spatie\Valuestore\Valuestore;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Tests\Etc\User; use Stancl\Tenancy\Tests\Etc\User;
use Stancl\JobPipeline\JobPipeline; use Stancl\JobPipeline\JobPipeline;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Jobs\CreateDatabase; use Stancl\Tenancy\Jobs\CreateDatabase;
use Illuminate\Queue\InteractsWithQueue;
use Stancl\Tenancy\Events\TenantCreated; use Stancl\Tenancy\Events\TenantCreated;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Queue\Events\JobProcessed; use Illuminate\Queue\Events\JobProcessed;
use Illuminate\Queue\Events\JobProcessing; use Illuminate\Queue\Events\JobProcessing;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Listeners\RevertToCentralContext;
@ -48,6 +48,8 @@ afterEach(function () {
}); });
test('tenant id is passed to tenant queues', function () { test('tenant id is passed to tenant queues', function () {
withTenantDatabases();
config(['queue.default' => 'sync']); config(['queue.default' => 'sync']);
$tenant = Tenant::create(); $tenant = Tenant::create();
@ -64,6 +66,8 @@ test('tenant id is passed to tenant queues', function () {
}); });
test('tenant id is not passed to central queues', function () { test('tenant id is not passed to central queues', function () {
withTenantDatabases();
$tenant = Tenant::create(); $tenant = Tenant::create();
tenancy()->initialize($tenant); tenancy()->initialize($tenant);
@ -156,6 +160,8 @@ test('tenancy is initialized when retrying jobs', function (bool $shouldEndTenan
})->with([true, false]); })->with([true, false]);
test('the tenant used by the job doesnt change when the current tenant changes', function () { test('the tenant used by the job doesnt change when the current tenant changes', function () {
withTenantDatabases();
$tenant1 = Tenant::create([ $tenant1 = Tenant::create([
'id' => 'acme', 'id' => 'acme',
]); ]);
@ -217,13 +223,6 @@ function withUsers()
}); });
} }
function withTenantDatabases()
{
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
}
class TestJob implements ShouldQueue class TestJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

View file

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
use Stancl\JobPipeline\JobPipeline;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\SessionTenancyBootstrapper;
use Stancl\Tenancy\Events;
use Stancl\Tenancy\Events\TenantCreated;
use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Listeners;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Tests\Etc\Tenant;
/**
* This collection of regression tests verifies that SessionTenancyBootstrapper
* fully fixes the issue described here https://github.com/archtechx/tenancy/issues/547
*
* This means: using the DB session driver and:
* 1) switching to the central context from tenant requests, OR
* 2) switching to the tenant context from central requests
*/
beforeEach(function () {
config(['session.driver' => 'database']);
config(['tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class]]);
Event::listen(
TenantCreated::class,
JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener()
);
Event::listen(Events\TenancyInitialized::class, Listeners\BootstrapTenancy::class);
Event::listen(Events\TenancyEnded::class, Listeners\RevertToCentralContext::class);
// Sessions table for central database
pest()->artisan('migrate', [
'--path' => __DIR__ . '/Etc/session_migrations',
'--realpath' => true,
])->assertExitCode(0);
});
test('central helper can be used in tenant requests', function (bool $enabled, bool $shouldThrow) {
if ($enabled) {
config()->set(
'tenancy.bootstrappers',
array_merge(config('tenancy.bootstrappers'), [SessionTenancyBootstrapper::class]),
);
}
$tenant = Tenant::create();
$tenant->domains()->create(['domain' => 'foo.localhost']);
// run for tenants
pest()->artisan('tenants:migrate', [
'--path' => __DIR__ . '/Etc/session_migrations',
'--realpath' => true,
])->assertExitCode(0);
Route::middleware(['web', InitializeTenancyByDomain::class])->get('/bar', function () {
session(['message' => 'tenant session']);
tenancy()->central(function () {
return 'central results';
});
return session('message');
});
// We initialize tenancy before making the request, since sessions work a bit differently in tests
// and we need the DB session handler to use the tenant connection (as it does in a real app on tenant requests).
tenancy()->initialize($tenant);
try {
$this->withoutExceptionHandling()
->get('http://foo.localhost/bar')
->assertOk()
->assertSee('tenant session');
if ($shouldThrow) {
pest()->fail('Exception not thrown');
}
} catch (Throwable $e) {
if ($shouldThrow) {
pest()->assertTrue(true); // empty assertion to make the test pass
} else {
pest()->fail('Exception thrown: ' . $e->getMessage());
}
}
})->with([
['enabled' => false, 'shouldThrow' => true],
['enabled' => true, 'shouldThrow' => false],
]);
test('tenant run helper can be used on central requests', function (bool $enabled, bool $shouldThrow) {
if ($enabled) {
config()->set(
'tenancy.bootstrappers',
array_merge(config('tenancy.bootstrappers'), [SessionTenancyBootstrapper::class]),
);
}
Tenant::create();
// run for tenants
pest()->artisan('tenants:migrate', [
'--path' => __DIR__ . '/Etc/session_migrations',
'--realpath' => true,
])->assertExitCode(0);
Route::middleware(['web'])->get('/bar', function () {
session(['message' => 'central session']);
Tenant::first()->run(function () {
return 'tenant results';
});
return session('message');
});
try {
$this->withoutExceptionHandling()
->get('http://localhost/bar')
->assertOk()
->assertSee('central session');
if ($shouldThrow) {
pest()->fail('Exception not thrown');
}
} catch (Throwable $e) {
if ($shouldThrow) {
pest()->assertTrue(true); // empty assertion to make the test pass
} else {
pest()->fail('Exception thrown: ' . $e->getMessage());
}
}
})->with([
['enabled' => false, 'shouldThrow' => true],
['enabled' => true, 'shouldThrow' => false],
]);

View file

@ -52,12 +52,13 @@ test('onfail logic can be customized', function () {
->assertSee('foo'); ->assertSee('foo');
}); });
test('localhost is not a valid subdomain', function () { test('archte.ch is not a valid subdomain', function () {
pest()->expectException(NotASubdomainException::class); pest()->expectException(NotASubdomainException::class);
// This gets routed to the app, but with a request domain of 'archte.ch'
$this $this
->withoutExceptionHandling() ->withoutExceptionHandling()
->get('http://localhost/foo/abc/xyz'); ->get('http://archte.ch/foo/abc/xyz');
}); });
test('ip address is not a valid subdomain', function () { test('ip address is not a valid subdomain', function () {
@ -65,7 +66,7 @@ test('ip address is not a valid subdomain', function () {
$this $this
->withoutExceptionHandling() ->withoutExceptionHandling()
->get('http://127.0.0.1/foo/abc/xyz'); ->get('http://127.0.0.2/foo/abc/xyz');
}); });
test('oninvalidsubdomain logic can be customized', function () { test('oninvalidsubdomain logic can be customized', function () {
@ -81,7 +82,7 @@ test('oninvalidsubdomain logic can be customized', function () {
$this $this
->withoutExceptionHandling() ->withoutExceptionHandling()
->get('http://127.0.0.1/foo/abc/xyz') ->get('http://127.0.0.2/foo/abc/xyz')
->assertSee('foo custom invalid subdomain handler'); ->assertSee('foo custom invalid subdomain handler');
}); });
@ -106,26 +107,6 @@ test('we cant use a subdomain that doesnt belong to our central domains', functi
->get('http://foo.localhost/foo/abc/xyz'); ->get('http://foo.localhost/foo/abc/xyz');
}); });
test('central domain is not a subdomain', function () {
config(['tenancy.central_domains' => [
'localhost',
]]);
$tenant = SubdomainTenant::create([
'id' => 'acme',
]);
$tenant->domains()->create([
'domain' => 'acme',
]);
pest()->expectException(NotASubdomainException::class);
$this
->withoutExceptionHandling()
->get('http://localhost/foo/abc/xyz');
});
class SubdomainTenant extends Models\Tenant class SubdomainTenant extends Models\Tenant
{ {
use HasDomains; use HasDomains;

View file

@ -390,6 +390,81 @@ test('path used by sqlite manager can be customized', function () {
expect(file_exists($customPath . '/' . $name))->toBeTrue(); expect(file_exists($customPath . '/' . $name))->toBeTrue();
}); });
test('the tenant connection template can be specified either by name or as a connection array', function () {
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
config([
'tenancy.database.managers.mysql' => MySQLDatabaseManager::class,
'tenancy.database.template_tenant_connection' => 'mysql',
]);
$name = 'foo' . Str::random(8);
$tenant = Tenant::create([
'tenancy_db_name' => $name,
]);
/** @var MySQLDatabaseManager $manager */
$manager = $tenant->database()->manager();
expect($manager->databaseExists($name))->toBeTrue();
expect($manager->database()->getConfig('host'))->toBe('mysql');
config([
'tenancy.database.template_tenant_connection' => [
'driver' => 'mysql',
'url' => null,
'host' => 'mysql2',
'port' => '3306',
'database' => 'main',
'username' => 'root',
'password' => 'password',
'unix_socket' => '',
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => [],
],
]);
$tenant = Tenant::create([
'tenancy_db_name' => $name,
]);
/** @var MySQLDatabaseManager $manager */
$manager = $tenant->database()->manager();
expect($manager->databaseExists($name))->toBeTrue(); // tenant connection works
expect($manager->database()->getConfig('host'))->toBe('mysql2');
});
test('partial tenant connection templates get merged into the central connection template', function () {
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
config([
'database.connections.central.url' => 'example.com',
'tenancy.database.template_tenant_connection' => [
'url' => null,
'host' => 'mysql2',
],
]);
$name = 'foo' . Str::random(8);
$tenant = Tenant::create([
'tenancy_db_name' => $name,
]);
/** @var MySQLDatabaseManager $manager */
$manager = $tenant->database()->manager();
expect($manager->databaseExists($name))->toBeTrue(); // tenant connection works
expect($manager->database()->getConfig('host'))->toBe('mysql2');
expect($manager->database()->getConfig('url'))->toBeNull();
});
// Datasets // Datasets
dataset('database_managers', [ dataset('database_managers', [
['mysql', MySQLDatabaseManager::class], ['mysql', MySQLDatabaseManager::class],

View file

@ -3,27 +3,24 @@
declare(strict_types=1); declare(strict_types=1);
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Features\UniversalRoutes; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Contracts\Http\Kernel;
afterEach(function () { test('a route can work in both central and tenant context', function (array $routeMiddleware, string|null $globalMiddleware) {
InitializeTenancyByDomain::$onFail = null; if ($globalMiddleware) {
}); app(Kernel::class)->pushMiddleware($globalMiddleware);
}
test('a route can work in both central and tenant context', function () {
Route::middlewareGroup('universal', []); Route::middlewareGroup('universal', []);
config(['tenancy.features' => [UniversalRoutes::class]]);
Route::get('/foo', function () { Route::get('/foo', function () {
return tenancy()->initialized return tenancy()->initialized
? 'Tenancy is initialized.' ? 'Tenancy is initialized.'
: 'Tenancy is not initialized.'; : 'Tenancy is not initialized.';
})->middleware(['universal', InitializeTenancyByDomain::class]); })->middleware($routeMiddleware);
pest()->get('http://localhost/foo')
->assertSuccessful()
->assertSee('Tenancy is not initialized.');
$tenant = Tenant::create([ $tenant = Tenant::create([
'id' => 'acme', 'id' => 'acme',
@ -32,28 +29,33 @@ test('a route can work in both central and tenant context', function () {
'domain' => 'acme.localhost', 'domain' => 'acme.localhost',
]); ]);
pest()->get('http://acme.localhost/foo') pest()->get("http://localhost/foo")
->assertSuccessful()
->assertSee('Tenancy is not initialized.');
pest()->get("http://acme.localhost/foo")
->assertSuccessful() ->assertSuccessful()
->assertSee('Tenancy is initialized.'); ->assertSee('Tenancy is initialized.');
}); })->with('identification types');
test('making one route universal doesnt make all routes universal', function () { test('making one route universal doesnt make all routes universal', function (array $routeMiddleware, string|null $globalMiddleware) {
Route::get('/bar', function () { if ($globalMiddleware) {
return tenant('id'); app(Kernel::class)->pushMiddleware($globalMiddleware);
})->middleware(InitializeTenancyByDomain::class); }
Route::middlewareGroup('universal', []); Route::middlewareGroup('universal', []);
config(['tenancy.features' => [UniversalRoutes::class]]);
Route::get('/foo', function () { Route::middleware($routeMiddleware)->group(function () {
return tenancy()->initialized Route::get('/nonuniversal', function () {
? 'Tenancy is initialized.' return tenant('id');
: 'Tenancy is not initialized.'; });
})->middleware(['universal', InitializeTenancyByDomain::class]);
pest()->get('http://localhost/foo') Route::get('/universal', function () {
->assertSuccessful() return tenancy()->initialized
->assertSee('Tenancy is not initialized.'); ? 'Tenancy is initialized.'
: 'Tenancy is not initialized.';
})->middleware('universal');
});
$tenant = Tenant::create([ $tenant = Tenant::create([
'id' => 'acme', 'id' => 'acme',
@ -62,16 +64,57 @@ test('making one route universal doesnt make all routes universal', function ()
'domain' => 'acme.localhost', 'domain' => 'acme.localhost',
]); ]);
pest()->get('http://acme.localhost/foo') pest()->get("http://localhost/universal")
->assertSuccessful()
->assertSee('Tenancy is not initialized.');
pest()->get("http://acme.localhost/universal")
->assertSuccessful() ->assertSuccessful()
->assertSee('Tenancy is initialized.'); ->assertSee('Tenancy is initialized.');
tenancy()->end(); tenancy()->end();
pest()->get('http://localhost/bar') pest()->get('http://localhost/nonuniversal')
->assertStatus(500); ->assertStatus(404);
pest()->get('http://acme.localhost/bar') pest()->get('http://acme.localhost/nonuniversal')
->assertSuccessful() ->assertSuccessful()
->assertSee('acme'); ->assertSee('acme');
}); })->with([
'early identification' => [
'route_middleware' => [PreventAccessFromUnwantedDomains::class],
'global_middleware' => InitializeTenancyByDomain::class,
],
'route-level identification' => [
'route_middleware' => [PreventAccessFromUnwantedDomains::class, InitializeTenancyByDomain::class],
'global_middleware' => null,
]
]);
test('it throws correct exception when route is universal and tenant does not exist', function (array $routeMiddleware, string|null $globalMiddleware) {
if ($globalMiddleware) {
app(Kernel::class)->pushMiddleware($globalMiddleware);
}
Route::middlewareGroup('universal', []);
Route::get('/foo', function () {
return tenancy()->initialized
? 'Tenancy is initialized.'
: 'Tenancy is not initialized.';
})->middleware($routeMiddleware);
pest()->expectException(TenantCouldNotBeIdentifiedOnDomainException::class);
$this->withoutExceptionHandling()->get('http://acme.localhost/foo');
})->with('identification types');
dataset('identification types', [
'early identification' => [
'route_middleware' => ['universal', PreventAccessFromUnwantedDomains::class],
'global_middleware' => InitializeTenancyByDomain::class,
],
'route-level identification' => [
'route_middleware' => ['universal', PreventAccessFromUnwantedDomains::class, InitializeTenancyByDomain::class],
'global_middleware' => null,
]
]);