mirror of
https://github.com/archtechx/tenancy.git
synced 2026-02-05 11:44:04 +00:00
Merge branch 'master' of github.com:archtechx/tenancy into unware-feature
This commit is contained in:
commit
b0560331ed
87 changed files with 2308 additions and 533 deletions
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
|
|
@ -15,7 +15,11 @@ jobs:
|
|||
|
||||
strategy:
|
||||
matrix:
|
||||
laravel: ['^9.0']
|
||||
include:
|
||||
- laravel: "^9.0"
|
||||
php: "8.0"
|
||||
- laravel: "^10.0"
|
||||
php: "8.2"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
|
@ -107,8 +111,12 @@ jobs:
|
|||
name: Static analysis (PHPStan)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.2'
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install composer dependencies
|
||||
run: composer install
|
||||
- name: Run phpstan
|
||||
run: vendor/bin/phpstan analyse
|
||||
run: vendor/bin/phpstan analyse --xdebug
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# add amd64 platform to support Mac M1
|
||||
FROM --platform=linux/amd64 shivammathur/node:latest-amd64
|
||||
|
||||
ARG PHP_VERSION=8.1
|
||||
ARG PHP_VERSION=8.2
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ RUN apt-get update \
|
|||
&& curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add - \
|
||||
&& curl https://packages.microsoft.com/config/ubuntu/20.04/prod.list > /etc/apt/sources.list.d/mssql-release.list \
|
||||
&& apt-get update \
|
||||
&& ACCEPT_EULA=Y apt-get install -y unixodbc-dev msodbcsql17
|
||||
&& ACCEPT_EULA=Y apt-get install -y unixodbc-dev=2.3.7 unixodbc=2.3.7 odbcinst1debian2=2.3.7 odbcinst=2.3.7 msodbcsql17
|
||||
|
||||
# set PHP version
|
||||
RUN update-alternatives --set php /usr/bin/php$PHP_VERSION \
|
||||
|
|
|
|||
8
INTERNAL.md
Normal file
8
INTERNAL.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Internal development notes
|
||||
|
||||
## Updating the docker image used by the GH action
|
||||
|
||||
1. Login in to Docker Hub: `docker login -u archtechx -p`
|
||||
2. Build the image (probably shut down docker-compose containers first): `docker-compose build --no-cache`
|
||||
3. Tag a new image: `docker tag tenancy_test archtechx/tenancy:latest`
|
||||
4. Push the image: `docker push archtechx/tenancy:latest`
|
||||
|
|
@ -4,14 +4,14 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Providers;
|
||||
|
||||
use Stancl\Tenancy\Jobs;
|
||||
use Stancl\Tenancy\Events;
|
||||
use Stancl\Tenancy\Listeners;
|
||||
use Stancl\Tenancy\Middleware;
|
||||
use Stancl\JobPipeline\JobPipeline;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Stancl\JobPipeline\JobPipeline;
|
||||
use Stancl\Tenancy\Events;
|
||||
use Stancl\Tenancy\Jobs;
|
||||
use Stancl\Tenancy\Listeners;
|
||||
use Stancl\Tenancy\Middleware;
|
||||
|
||||
class TenancyServiceProvider extends ServiceProvider
|
||||
{
|
||||
|
|
@ -28,14 +28,16 @@ class TenancyServiceProvider extends ServiceProvider
|
|||
Jobs\CreateDatabase::class,
|
||||
Jobs\MigrateDatabase::class,
|
||||
// Jobs\SeedDatabase::class,
|
||||
Jobs\CreateStorageSymlinks::class,
|
||||
|
||||
// Jobs\CreateStorageSymlinks::class,
|
||||
|
||||
// Your own jobs to prepare the tenant.
|
||||
// Provision API keys, create S3 buckets, anything you want!
|
||||
|
||||
])->send(function (Events\TenantCreated $event) {
|
||||
return $event->tenant;
|
||||
})->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production.
|
||||
|
||||
// Listeners\CreateTenantStorage::class,
|
||||
],
|
||||
Events\SavingTenant::class => [],
|
||||
Events\TenantSaved::class => [],
|
||||
|
|
@ -53,7 +55,7 @@ class TenancyServiceProvider extends ServiceProvider
|
|||
Events\TenantDeleted::class => [
|
||||
JobPipeline::make([
|
||||
Jobs\DeleteDatabase::class,
|
||||
Jobs\RemoveStorageSymlinks::class,
|
||||
// Jobs\RemoveStorageSymlinks::class,
|
||||
])->send(function (Events\TenantDeleted $event) {
|
||||
return $event->tenant;
|
||||
})->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production.
|
||||
|
|
@ -116,6 +118,21 @@ class TenancyServiceProvider extends ServiceProvider
|
|||
];
|
||||
}
|
||||
|
||||
protected function overrideUrlInTenantContext(): void
|
||||
{
|
||||
/**
|
||||
* Example of CLI tenant URL root override:
|
||||
*
|
||||
* UrlTenancyBootstrapper::$rootUrlOverride = function (Tenant $tenant) {
|
||||
* $baseUrl = url('/');
|
||||
* $scheme = str($baseUrl)->before('://');
|
||||
* $hostname = str($baseUrl)->after($scheme . '://');
|
||||
*
|
||||
* return $scheme . '://' . $tenant->getTenantKey() . '.' . $hostname;
|
||||
*};
|
||||
*/
|
||||
}
|
||||
|
||||
public function register()
|
||||
{
|
||||
//
|
||||
|
|
@ -127,6 +144,7 @@ class TenancyServiceProvider extends ServiceProvider
|
|||
$this->mapRoutes();
|
||||
|
||||
$this->makeTenancyMiddlewareHighestPriority();
|
||||
$this->overrideUrlInTenantContext();
|
||||
}
|
||||
|
||||
protected function bootEvents()
|
||||
|
|
@ -153,7 +171,7 @@ class TenancyServiceProvider extends ServiceProvider
|
|||
protected function makeTenancyMiddlewareHighestPriority()
|
||||
{
|
||||
// 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) {
|
||||
$this->app[\Illuminate\Contracts\Http\Kernel::class]->prependToMiddlewarePriority($middleware);
|
||||
|
|
|
|||
|
|
@ -102,6 +102,9 @@ return [
|
|||
Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class,
|
||||
Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class,
|
||||
Stancl\Tenancy\Bootstrappers\BatchTenancyBootstrapper::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\RedisTenancyBootstrapper::class, // Note: phpredis is needed
|
||||
],
|
||||
|
||||
|
|
@ -255,7 +258,7 @@ return [
|
|||
],
|
||||
|
||||
/**
|
||||
* Redis tenancy config. Used by RedisTenancyBoostrapper.
|
||||
* Redis tenancy config. Used by RedisTenancyBootstrapper.
|
||||
*
|
||||
* Note: You need phpredis to use Redis tenancy.
|
||||
*
|
||||
|
|
@ -281,9 +284,9 @@ return [
|
|||
'features' => [
|
||||
// Stancl\Tenancy\Features\UserImpersonation::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\CrossDomainRedirect::class, // https://tenancyforlaravel.com/docs/v3/features/cross-domain-redirect
|
||||
// Stancl\Tenancy\Features\ViteBundler::class,
|
||||
],
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tenant_resources', function (Blueprint $table) {
|
||||
$table->increments('id');
|
||||
$table->string('tenant_id');
|
||||
$table->string('resource_global_id');
|
||||
$table->string('tenant_resources_type');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('tenant_resources');
|
||||
}
|
||||
};
|
||||
|
|
@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
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([
|
||||
'web',
|
||||
InitializeTenancyByDomain::class,
|
||||
PreventAccessFromCentralDomains::class,
|
||||
PreventAccessFromUnwantedDomains::class,
|
||||
])->group(function () {
|
||||
Route::get('/', function () {
|
||||
return 'This is your multi-tenant application. The id of the current tenant is ' . tenant('id');
|
||||
|
|
|
|||
|
|
@ -15,22 +15,24 @@
|
|||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.1",
|
||||
"php": "^8.2",
|
||||
"ext-json": "*",
|
||||
"illuminate/support": "^9.0",
|
||||
"illuminate/support": "^9.38|^10.0",
|
||||
"facade/ignition-contracts": "^1.0.2",
|
||||
"spatie/ignition": "^1.4",
|
||||
"ramsey/uuid": "^4.0",
|
||||
"stancl/jobpipeline": "^1.0",
|
||||
"stancl/virtualcolumn": "^1.3"
|
||||
"ramsey/uuid": "^4.7.3",
|
||||
"stancl/jobpipeline": "^1.6.2",
|
||||
"stancl/virtualcolumn": "^1.3.1",
|
||||
"spatie/invade": "^1.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/framework": "^9.0",
|
||||
"orchestra/testbench": "^7.0",
|
||||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
"doctrine/dbal": "^2.10",
|
||||
"laravel/framework": "^9.38|^10.0",
|
||||
"orchestra/testbench": "^7.0|^8.0",
|
||||
"league/flysystem-aws-s3-v3": "^3.12.2",
|
||||
"doctrine/dbal": "^3.6.0",
|
||||
"spatie/valuestore": "^1.2.5",
|
||||
"pestphp/pest": "^1.21",
|
||||
"nunomaduro/larastan": "^1.0",
|
||||
"nunomaduro/larastan": "^2.4",
|
||||
"spatie/invade": "^1.1"
|
||||
},
|
||||
"autoload": {
|
||||
|
|
@ -58,16 +60,16 @@
|
|||
}
|
||||
},
|
||||
"scripts": {
|
||||
"docker-up": "PHP_VERSION=8.1 docker-compose up -d",
|
||||
"docker-down": "PHP_VERSION=8.1 docker-compose down",
|
||||
"docker-rebuild": "PHP_VERSION=8.1 docker-compose up -d --no-deps --build",
|
||||
"docker-up": "PHP_VERSION=8.2 docker-compose up -d",
|
||||
"docker-down": "PHP_VERSION=8.2 docker-compose down",
|
||||
"docker-rebuild": "PHP_VERSION=8.2 docker-compose up -d --no-deps --build",
|
||||
"docker-m1": "ln -s docker-compose-m1.override.yml docker-compose.override.yml",
|
||||
"coverage": "open coverage/phpunit/html/index.html",
|
||||
"phpstan": "vendor/bin/phpstan",
|
||||
"phpstan-pro": "vendor/bin/phpstan --pro",
|
||||
"cs": "php-cs-fixer fix --config=.php-cs-fixer.php",
|
||||
"test": "PHP_VERSION=8.1 ./test --no-coverage",
|
||||
"test-full": "PHP_VERSION=8.1 ./test"
|
||||
"test": "PHP_VERSION=8.2 ./test --no-coverage",
|
||||
"test-full": "PHP_VERSION=8.2 ./test"
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ services:
|
|||
build:
|
||||
context: .
|
||||
args:
|
||||
PHP_VERSION: ${PHP_VERSION:-8.1}
|
||||
PHP_VERSION: ${PHP_VERSION:-8.2}
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
14
phpstan.neon
14
phpstan.neon
|
|
@ -23,12 +23,13 @@ parameters:
|
|||
- src/Commands/ClearPendingTenants.php
|
||||
- src/Database/Concerns/PendingScope.php
|
||||
- src/Database/ParentModelScope.php
|
||||
- '#Call to an undefined method Illuminate\\Database\\Eloquent\\Builder\:\:withPending\(\)#'
|
||||
-
|
||||
message: '#invalid type Laravel\\Telescope\\IncomingEntry#'
|
||||
paths:
|
||||
- src/Features/TelescopeTags.php
|
||||
-
|
||||
message: '#Parameter \#1 \$key of method Illuminate\\Contracts\\Cache\\Repository::put\(\) expects string#'
|
||||
message: '#Parameter \#1 \$key of method Illuminate\\Cache\\Repository::put\(\) expects#'
|
||||
paths:
|
||||
- src/helpers.php
|
||||
-
|
||||
|
|
@ -39,15 +40,18 @@ parameters:
|
|||
message: '#Illuminate\\Routing\\UrlGenerator#'
|
||||
paths:
|
||||
- src/Bootstrappers/FilesystemTenancyBootstrapper.php
|
||||
-
|
||||
message: '#select\(\) expects string, Illuminate\\Database\\Query\\Expression given#'
|
||||
paths:
|
||||
- src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php
|
||||
-
|
||||
message: '#Trying to invoke Closure\|null but it might not be a callable#'
|
||||
paths:
|
||||
- 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#'
|
||||
- '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$tenancy#'
|
||||
- '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$resolver#'
|
||||
|
||||
checkMissingIterableValueType: false
|
||||
checkGenericClassInNonGenericObjectType: false # later we may want to enable this
|
||||
treatPhpDocTypesAsCertain: false
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
</filter>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_KEY" value="base64:uYVmTs9lrQbXWfHgSSiG0VZMjc2KG/fBbjV1i1JDVos="/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="CACHE_DRIVER" value="redis"/>
|
||||
<env name="MAIL_DRIVER" value="array"/>
|
||||
|
|
|
|||
95
src/Bootstrappers/BroadcastTenancyBootstrapper.php
Normal file
95
src/Bootstrappers/BroadcastTenancyBootstrapper.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Bootstrappers;
|
||||
|
||||
use Illuminate\Broadcasting\BroadcastManager;
|
||||
use Illuminate\Config\Repository;
|
||||
use Illuminate\Contracts\Broadcasting\Broadcaster;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
|
||||
use Stancl\Tenancy\Contracts\Tenant;
|
||||
use Stancl\Tenancy\TenancyBroadcastManager;
|
||||
|
||||
class BroadcastTenancyBootstrapper implements TenancyBootstrapper
|
||||
{
|
||||
/**
|
||||
* Tenant properties to be mapped to config (similarly to the TenantConfig feature).
|
||||
*
|
||||
* For example:
|
||||
* [
|
||||
* 'config.key.name' => 'tenant_property',
|
||||
* ]
|
||||
*/
|
||||
public static array $credentialsMap = [];
|
||||
|
||||
public static string|null $broadcaster = null;
|
||||
|
||||
protected array $originalConfig = [];
|
||||
protected BroadcastManager|null $originalBroadcastManager = null;
|
||||
protected Broadcaster|null $originalBroadcaster = null;
|
||||
|
||||
public static array $mapPresets = [
|
||||
'pusher' => [
|
||||
'broadcasting.connections.pusher.key' => 'pusher_key',
|
||||
'broadcasting.connections.pusher.secret' => 'pusher_secret',
|
||||
'broadcasting.connections.pusher.app_id' => 'pusher_app_id',
|
||||
'broadcasting.connections.pusher.options.cluster' => 'pusher_cluster',
|
||||
],
|
||||
'ably' => [
|
||||
'broadcasting.connections.ably.key' => 'ably_key',
|
||||
'broadcasting.connections.ably.public' => 'ably_public',
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
protected Repository $config,
|
||||
protected Application $app
|
||||
) {
|
||||
static::$broadcaster ??= $config->get('broadcasting.default');
|
||||
static::$credentialsMap = array_merge(static::$credentialsMap, static::$mapPresets[static::$broadcaster] ?? []);
|
||||
}
|
||||
|
||||
public function bootstrap(Tenant $tenant): void
|
||||
{
|
||||
$this->originalBroadcastManager = $this->app->make(BroadcastManager::class);
|
||||
$this->originalBroadcaster = $this->app->make(Broadcaster::class);
|
||||
|
||||
$this->setConfig($tenant);
|
||||
|
||||
// Make BroadcastManager resolve to a custom BroadcastManager which makes the broadcasters use the tenant credentials
|
||||
$this->app->extend(BroadcastManager::class, function (BroadcastManager $broadcastManager) {
|
||||
return new TenancyBroadcastManager($this->app);
|
||||
});
|
||||
}
|
||||
|
||||
public function revert(): void
|
||||
{
|
||||
// Change the BroadcastManager and Broadcaster singletons back to what they were before initializing tenancy
|
||||
$this->app->singleton(BroadcastManager::class, fn (Application $app) => $this->originalBroadcastManager);
|
||||
$this->app->singleton(Broadcaster::class, fn (Application $app) => $this->originalBroadcaster);
|
||||
|
||||
$this->unsetConfig();
|
||||
}
|
||||
|
||||
protected function setConfig(Tenant $tenant): void
|
||||
{
|
||||
foreach (static::$credentialsMap as $configKey => $storageKey) {
|
||||
$override = $tenant->$storageKey;
|
||||
|
||||
if (array_key_exists($storageKey, $tenant->getAttributes())) {
|
||||
$this->originalConfig[$configKey] ??= $this->config->get($configKey);
|
||||
|
||||
$this->config->set($configKey, $override);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function unsetConfig(): void
|
||||
{
|
||||
foreach ($this->originalConfig as $key => $value) {
|
||||
$this->config->set($key, $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper
|
|||
/** @var TenantWithDatabase $tenant */
|
||||
|
||||
// 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();
|
||||
if (! $tenant->database()->manager()->databaseExists($database)) {
|
||||
throw new TenantDatabaseDoesNotExistException($database);
|
||||
|
|
|
|||
79
src/Bootstrappers/MailTenancyBootstrapper.php
Normal file
79
src/Bootstrappers/MailTenancyBootstrapper.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Bootstrappers;
|
||||
|
||||
use Illuminate\Config\Repository;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
|
||||
use Stancl\Tenancy\Contracts\Tenant;
|
||||
|
||||
class MailTenancyBootstrapper implements TenancyBootstrapper
|
||||
{
|
||||
/**
|
||||
* Tenant properties to be mapped to config (similarly to the TenantConfig feature).
|
||||
*
|
||||
* For example:
|
||||
* [
|
||||
* 'config.key.name' => 'tenant_property',
|
||||
* ]
|
||||
*/
|
||||
public static array $credentialsMap = [];
|
||||
|
||||
public static string|null $mailer = null;
|
||||
|
||||
protected array $originalConfig = [];
|
||||
|
||||
public static array $mapPresets = [
|
||||
'smtp' => [
|
||||
'mail.mailers.smtp.host' => 'smtp_host',
|
||||
'mail.mailers.smtp.port' => 'smtp_port',
|
||||
'mail.mailers.smtp.username' => 'smtp_username',
|
||||
'mail.mailers.smtp.password' => 'smtp_password',
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
protected Repository $config,
|
||||
protected Application $app
|
||||
) {
|
||||
static::$mailer ??= $config->get('mail.default');
|
||||
static::$credentialsMap = array_merge(static::$credentialsMap, static::$mapPresets[static::$mailer] ?? []);
|
||||
}
|
||||
|
||||
public function bootstrap(Tenant $tenant): void
|
||||
{
|
||||
// Forget the mail manager instance to clear the cached mailers
|
||||
$this->app->forgetInstance('mail.manager');
|
||||
|
||||
$this->setConfig($tenant);
|
||||
}
|
||||
|
||||
public function revert(): void
|
||||
{
|
||||
$this->unsetConfig();
|
||||
|
||||
$this->app->forgetInstance('mail.manager');
|
||||
}
|
||||
|
||||
protected function setConfig(Tenant $tenant): void
|
||||
{
|
||||
foreach (static::$credentialsMap as $configKey => $storageKey) {
|
||||
$override = $tenant->$storageKey;
|
||||
|
||||
if (array_key_exists($storageKey, $tenant->getAttributes())) {
|
||||
$this->originalConfig[$configKey] ??= $this->config->get($configKey);
|
||||
|
||||
$this->config->set($configKey, $override);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function unsetConfig(): void
|
||||
{
|
||||
foreach ($this->originalConfig as $key => $value) {
|
||||
$this->config->set($key, $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -79,9 +79,9 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
|
|||
$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
|
||||
if (tenancy()->initialized) {
|
||||
// Tenancy was initialized, so we revert back to the central context
|
||||
|
|
|
|||
66
src/Bootstrappers/SessionTenancyBootstrapper.php
Normal file
66
src/Bootstrappers/SessionTenancyBootstrapper.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
41
src/Bootstrappers/UrlTenancyBootstrapper.php
Normal file
41
src/Bootstrappers/UrlTenancyBootstrapper.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Bootstrappers;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Config\Repository;
|
||||
use Illuminate\Contracts\Routing\UrlGenerator;
|
||||
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
|
||||
use Stancl\Tenancy\Contracts\Tenant;
|
||||
|
||||
class UrlTenancyBootstrapper implements TenancyBootstrapper
|
||||
{
|
||||
public static Closure|null $rootUrlOverride = null;
|
||||
protected string|null $originalRootUrl = null;
|
||||
|
||||
public function __construct(
|
||||
protected UrlGenerator $urlGenerator,
|
||||
protected Repository $config,
|
||||
) {
|
||||
}
|
||||
|
||||
public function bootstrap(Tenant $tenant): void
|
||||
{
|
||||
$this->originalRootUrl = $this->urlGenerator->to('/');
|
||||
|
||||
if (static::$rootUrlOverride) {
|
||||
$newRootUrl = (static::$rootUrlOverride)($tenant);
|
||||
|
||||
$this->urlGenerator->forceRootUrl($newRootUrl);
|
||||
$this->config->set('app.url', $newRootUrl);
|
||||
}
|
||||
}
|
||||
|
||||
public function revert(): void
|
||||
{
|
||||
$this->urlGenerator->forceRootUrl($this->originalRootUrl);
|
||||
$this->config->set('app.url', $this->originalRootUrl);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,6 @@ use Illuminate\Database\Eloquent\Builder;
|
|||
class ClearPendingTenants extends Command
|
||||
{
|
||||
protected $signature = 'tenants:pending-clear
|
||||
{--all : Override the default settings and deletes all pending tenants}
|
||||
{--older-than-days= : Deletes all pending tenants older than the amount of days}
|
||||
{--older-than-hours= : Deletes all pending tenants older than the amount of hours}';
|
||||
|
||||
|
|
@ -18,38 +17,30 @@ class ClearPendingTenants extends Command
|
|||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Removing pending tenants.');
|
||||
$this->components->info('Removing pending tenants.');
|
||||
|
||||
$expirationDate = now();
|
||||
// We compare the original expiration date to the new one to check if the new one is different later
|
||||
$originalExpirationDate = $expirationDate->copy()->toImmutable();
|
||||
|
||||
// Skip the time constraints if the 'all' option is given
|
||||
if (! $this->option('all')) {
|
||||
/** @var ?int $olderThanDays */
|
||||
$olderThanDays = $this->option('older-than-days');
|
||||
$olderThanDays = (int) $this->option('older-than-days');
|
||||
$olderThanHours = (int) $this->option('older-than-hours');
|
||||
|
||||
/** @var ?int $olderThanHours */
|
||||
$olderThanHours = $this->option('older-than-hours');
|
||||
if ($olderThanDays && $olderThanHours) {
|
||||
$this->components->error("Cannot use '--older-than-days' and '--older-than-hours' together. Please, choose only one of these options.");
|
||||
|
||||
if ($olderThanDays && $olderThanHours) {
|
||||
$this->line("<options=bold,reverse;fg=red> Cannot use '--older-than-days' and '--older-than-hours' together \n"); // todo@cli refactor all of these styled command outputs to use $this->components
|
||||
$this->line('Please, choose only one of these options.');
|
||||
|
||||
return 1; // Exit code for failure
|
||||
}
|
||||
|
||||
if ($olderThanDays) {
|
||||
$expirationDate->subDays($olderThanDays);
|
||||
}
|
||||
|
||||
if ($olderThanHours) {
|
||||
$expirationDate->subHours($olderThanHours);
|
||||
}
|
||||
return 1; // Exit code for failure
|
||||
}
|
||||
|
||||
$deletedTenantCount = tenancy()
|
||||
->query()
|
||||
if ($olderThanDays) {
|
||||
$expirationDate->subDays($olderThanDays);
|
||||
}
|
||||
|
||||
if ($olderThanHours) {
|
||||
$expirationDate->subHours($olderThanHours);
|
||||
}
|
||||
|
||||
$deletedTenantCount = tenancy()->query()
|
||||
->onlyPending()
|
||||
->when($originalExpirationDate->notEqualTo($expirationDate), function (Builder $query) use ($expirationDate) {
|
||||
$query->where($query->getModel()->getColumnForQuery('pending_since'), '<', $expirationDate->timestamp);
|
||||
|
|
@ -59,7 +50,7 @@ class ClearPendingTenants extends Command
|
|||
->delete()
|
||||
->count();
|
||||
|
||||
$this->info($deletedTenantCount . ' pending ' . str('tenant')->plural($deletedTenantCount) . ' deleted.');
|
||||
$this->components->info($deletedTenantCount . ' pending ' . str('tenant')->plural($deletedTenantCount) . ' deleted.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ class CreatePendingTenants extends Command
|
|||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Creating pending tenants.');
|
||||
$this->components->info('Creating pending tenants.');
|
||||
|
||||
$maxPendingTenantCount = (int) ($this->option('count') ?? config('tenancy.pending.count'));
|
||||
$pendingTenantCount = $this->getPendingTenantCount();
|
||||
|
|
@ -30,8 +30,8 @@ class CreatePendingTenants extends Command
|
|||
$createdCount++;
|
||||
}
|
||||
|
||||
$this->info($createdCount . ' ' . str('tenant')->plural($createdCount) . ' created.');
|
||||
$this->info($maxPendingTenantCount . ' ' . str('tenant')->plural($maxPendingTenantCount) . ' ready to be used.');
|
||||
$this->components->info($createdCount . ' pending ' . str('tenant')->plural($createdCount) . ' created.');
|
||||
$this->components->info($maxPendingTenantCount . ' pending ' . str('tenant')->plural($maxPendingTenantCount) . ' ready to be used.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -39,8 +39,7 @@ class CreatePendingTenants extends Command
|
|||
/** Calculate the number of currently available pending tenants. */
|
||||
protected function getPendingTenantCount(): int
|
||||
{
|
||||
return tenancy()
|
||||
->query()
|
||||
return tenancy()->query()
|
||||
->onlyPending()
|
||||
->count();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ class Link extends Command
|
|||
$this->createLinks($tenants);
|
||||
}
|
||||
} catch (Exception $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
$this->components->error($exception->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ namespace Stancl\Tenancy\Commands;
|
|||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Illuminate\Database\Console\Migrations\MigrateCommand;
|
||||
use Illuminate\Database\Migrations\Migrator;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Stancl\Tenancy\Concerns\DealsWithMigrations;
|
||||
use Stancl\Tenancy\Concerns\ExtendsLaravelCommand;
|
||||
use Stancl\Tenancy\Concerns\HasTenantOptions;
|
||||
use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException;
|
||||
use Stancl\Tenancy\Events\DatabaseMigrated;
|
||||
use Stancl\Tenancy\Events\MigratingDatabase;
|
||||
|
||||
|
|
@ -28,6 +30,8 @@ class Migrate extends MigrateCommand
|
|||
{
|
||||
parent::__construct($migrator, $dispatcher);
|
||||
|
||||
$this->addOption('skip-failing');
|
||||
|
||||
$this->specifyParameters();
|
||||
}
|
||||
|
||||
|
|
@ -43,16 +47,23 @@ class Migrate extends MigrateCommand
|
|||
return 1;
|
||||
}
|
||||
|
||||
tenancy()->runForMultiple($this->getTenants(), function ($tenant) {
|
||||
$this->components->info("Tenant: {$tenant->getTenantKey()}");
|
||||
foreach ($this->getTenants() as $tenant) {
|
||||
try {
|
||||
$tenant->run(function ($tenant) {
|
||||
$this->line("Tenant: {$tenant->getTenantKey()}");
|
||||
|
||||
event(new MigratingDatabase($tenant));
|
||||
event(new MigratingDatabase($tenant));
|
||||
// Migrate
|
||||
parent::handle();
|
||||
|
||||
// Migrate
|
||||
parent::handle();
|
||||
|
||||
event(new DatabaseMigrated($tenant));
|
||||
});
|
||||
event(new DatabaseMigrated($tenant));
|
||||
});
|
||||
} catch (TenantDatabaseDoesNotExistException|QueryException $th) {
|
||||
if (! $this->option('skip-failing')) {
|
||||
throw $th;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ class TenantDump extends DumpCommand
|
|||
public function handle(ConnectionResolverInterface $connections, Dispatcher $dispatcher): int
|
||||
{
|
||||
if (is_null($this->option('path'))) {
|
||||
$this->input->setOption('path', database_path('schema/tenant-schema.dump'));
|
||||
$this->input->setOption('path', config('tenancy.migration_parameters.--schema-path') ?? database_path('schema/tenant-schema.dump'));
|
||||
}
|
||||
|
||||
$tenant = $this->option('tenant')
|
||||
|
|
@ -41,7 +41,7 @@ class TenantDump extends DumpCommand
|
|||
return 1;
|
||||
}
|
||||
|
||||
parent::handle($connections, $dispatcher);
|
||||
$tenant->run(fn () => parent::handle($connections, $dispatcher));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -23,8 +23,7 @@ trait HasTenantOptions
|
|||
|
||||
protected function getTenants(): LazyCollection
|
||||
{
|
||||
return tenancy()
|
||||
->query()
|
||||
return tenancy()->query()
|
||||
->when($this->option('tenants'), function ($query) {
|
||||
$query->whereIn(tenancy()->model()->getTenantKeyName(), $this->option('tenants'));
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
*
|
||||
* @see \Stancl\Tenancy\Database\Models\Domain
|
||||
*
|
||||
* @method __call(string $method, array $parameters) IDE support. This will be a model.
|
||||
* @method __call(string $method, array $parameters) IDE support. This will be a model. // todo check if we can remove these now
|
||||
* @method static __callStatic(string $method, array $parameters) IDE support. This will be a model.
|
||||
* @mixin \Illuminate\Database\Eloquent\Model
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Stancl\Tenancy\Database\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Stancl\Tenancy\Contracts\Tenant;
|
||||
use Stancl\Tenancy\Database\TenantScope;
|
||||
use Stancl\Tenancy\Tenancy;
|
||||
|
|
@ -13,7 +14,7 @@ use Stancl\Tenancy\Tenancy;
|
|||
*/
|
||||
trait BelongsToTenant
|
||||
{
|
||||
public function tenant()
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(config('tenancy.models.tenant'), Tenancy::tenantKeyColumn());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ declare(strict_types=1);
|
|||
|
||||
namespace Stancl\Tenancy\Database\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\MorphToMany;
|
||||
use Stancl\Tenancy\Contracts\Syncable;
|
||||
use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
|
||||
use Stancl\Tenancy\Database\Models\TenantMorphPivot;
|
||||
use Stancl\Tenancy\Events\SyncedResourceSaved;
|
||||
|
||||
trait ResourceSyncing
|
||||
|
|
@ -43,4 +45,10 @@ trait ResourceSyncing
|
|||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function tenants(): MorphToMany
|
||||
{
|
||||
return $this->morphToMany(config('tenancy.models.tenant'), 'tenant_resources', 'tenant_resources', 'resource_global_id', 'tenant_id', 'global_id')
|
||||
->using(TenantMorphPivot::class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
21
src/Database/Concerns/TriggerSyncEvent.php
Normal file
21
src/Database/Concerns/TriggerSyncEvent.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Database\Concerns;
|
||||
|
||||
use Stancl\Tenancy\Contracts\Syncable;
|
||||
|
||||
trait TriggerSyncEvent
|
||||
{
|
||||
public static function booted(): void
|
||||
{
|
||||
static::saved(function (self $pivot) {
|
||||
$parent = $pivot->pivotParent;
|
||||
|
||||
if ($parent instanceof Syncable && $parent->shouldSync()) {
|
||||
$parent->triggerSyncEvent();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -87,7 +87,7 @@ class DatabaseConfig
|
|||
{
|
||||
$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_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')
|
||||
?? config('tenancy.database.template_tenant_connection')
|
||||
?? config('tenancy.database.central_connection');
|
||||
return $this->getTemplateConnection()['driver'];
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -114,8 +132,7 @@ class DatabaseConfig
|
|||
*/
|
||||
public function connection(): array
|
||||
{
|
||||
$template = $this->getTemplateConnectionName();
|
||||
$templateConnection = config("database.connections.{$template}");
|
||||
$templateConnection = $this->getTemplateConnection();
|
||||
|
||||
return $this->manager()->makeConnectionConfig(
|
||||
array_merge($templateConnection, $this->tenantConfig()),
|
||||
|
|
@ -129,10 +146,9 @@ class DatabaseConfig
|
|||
public function hostConnection(): array
|
||||
{
|
||||
$config = $this->tenantConfig();
|
||||
$template = $this->getTemplateConnectionName();
|
||||
$templateConnection = config("database.connections.{$template}");
|
||||
$templateConnection = $this->getTemplateConnection();
|
||||
|
||||
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
|
||||
// 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
|
||||
|
|
@ -196,7 +212,7 @@ class DatabaseConfig
|
|||
$tenantHostConnectionName = $this->getTenantHostConnectionName();
|
||||
config(["database.connections.{$tenantHostConnectionName}" => $this->hostConnection()]);
|
||||
|
||||
$manager = $this->connectionDriverManager($tenantHostConnectionName);
|
||||
$manager = $this->connectionDriverManager(config("database.connections.{$tenantHostConnectionName}.driver"));
|
||||
|
||||
if ($manager instanceof Contracts\StatefulTenantDatabaseManager) {
|
||||
$manager->setConnection($tenantHostConnectionName);
|
||||
|
|
@ -211,10 +227,8 @@ class DatabaseConfig
|
|||
*
|
||||
* @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');
|
||||
|
||||
if (! array_key_exists($driver, $databaseManagers)) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use Stancl\Tenancy\Contracts;
|
|||
use Stancl\Tenancy\Contracts\Tenant;
|
||||
use Stancl\Tenancy\Database\Concerns;
|
||||
use Stancl\Tenancy\Events;
|
||||
use Stancl\Tenancy\Tenancy;
|
||||
|
||||
/**
|
||||
* @property string $domain
|
||||
|
|
@ -28,7 +29,7 @@ class Domain extends Model implements Contracts\Domain
|
|||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(config('tenancy.models.tenant'));
|
||||
return $this->belongsTo(config('tenancy.models.tenant'), Tenancy::tenantKeyColumn());
|
||||
}
|
||||
|
||||
protected $dispatchesEvents = [
|
||||
|
|
|
|||
|
|
@ -33,9 +33,8 @@ class ImpersonationToken extends Model
|
|||
public $incrementing = false;
|
||||
|
||||
protected $table = 'tenant_user_impersonation_tokens';
|
||||
|
||||
protected $dates = [
|
||||
'created_at',
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public static function booted(): void
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ class Tenant extends Model implements Contracts\Tenant
|
|||
Concerns\InitializationHelpers,
|
||||
Concerns\InvalidatesResolverCache;
|
||||
|
||||
protected static $modelsShouldPreventAccessingMissingAttributes = false;
|
||||
|
||||
protected $table = 'tenants';
|
||||
protected $primaryKey = 'id';
|
||||
protected $guarded = [];
|
||||
|
|
|
|||
13
src/Database/Models/TenantMorphPivot.php
Normal file
13
src/Database/Models/TenantMorphPivot.php
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Database\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\MorphPivot;
|
||||
use Stancl\Tenancy\Database\Concerns\TriggerSyncEvent;
|
||||
|
||||
class TenantMorphPivot extends MorphPivot
|
||||
{
|
||||
use TriggerSyncEvent;
|
||||
}
|
||||
|
|
@ -5,18 +5,9 @@ declare(strict_types=1);
|
|||
namespace Stancl\Tenancy\Database\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
use Stancl\Tenancy\Contracts\Syncable;
|
||||
use Stancl\Tenancy\Database\Concerns\TriggerSyncEvent;
|
||||
|
||||
class TenantPivot extends Pivot
|
||||
{
|
||||
public static function booted(): void
|
||||
{
|
||||
static::saved(function (self $pivot) {
|
||||
$parent = $pivot->pivotParent;
|
||||
|
||||
if ($parent instanceof Syncable && $parent->shouldSync()) {
|
||||
$parent->triggerSyncEvent();
|
||||
}
|
||||
});
|
||||
}
|
||||
use TriggerSyncEvent;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl
|
|||
|
||||
protected function isVersion8(): bool
|
||||
{
|
||||
$version = $this->database()->select($this->database()->raw('select version()'))[0]->{'version()'};
|
||||
$versionSelect = (string) $this->database()->raw('select version()')->getValue($this->database()->getQueryGrammar());
|
||||
$version = $this->database()->select($versionSelect)[0]->{'version()'};
|
||||
|
||||
return version_compare($version, '8.0.0') >= 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Enums;
|
||||
|
||||
enum LogMode
|
||||
{
|
||||
case NONE;
|
||||
case SILENT;
|
||||
case INSTANT;
|
||||
}
|
||||
|
|
@ -1,68 +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;
|
||||
|
||||
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(): 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;
|
||||
}
|
||||
|
||||
public static function alwaysBootstrap(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -48,11 +48,26 @@ class UserImpersonation implements Feature
|
|||
|
||||
$token->delete();
|
||||
|
||||
session()->put('tenancy_impersonating', true);
|
||||
|
||||
return redirect($token->redirect_url);
|
||||
}
|
||||
|
||||
public static function alwaysBootstrap(): bool
|
||||
{
|
||||
return false;
|
||||
public static function isImpersonating(): bool
|
||||
{
|
||||
return session()->has('tenancy_impersonating');
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout from the current domain and forget impersonation session.
|
||||
*/
|
||||
public static function leave(): void // todo possibly rename
|
||||
{
|
||||
auth()->logout();
|
||||
|
||||
session()->forget('tenancy_impersonating');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
src/Features/ViteBundler.php
Normal file
26
src/Features/ViteBundler.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Features;
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Stancl\Tenancy\Contracts\Feature;
|
||||
use Stancl\Tenancy\Tenancy;
|
||||
use Stancl\Tenancy\Vite;
|
||||
|
||||
class ViteBundler implements Feature
|
||||
{
|
||||
/** @var Application */
|
||||
protected $app;
|
||||
|
||||
public function __construct(Application $app)
|
||||
{
|
||||
$this->app = $app;
|
||||
}
|
||||
|
||||
public function bootstrap(Tenancy $tenancy): void
|
||||
{
|
||||
$this->app->singleton(\Illuminate\Foundation\Vite::class, Vite::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ namespace Stancl\Tenancy\Listeners;
|
|||
|
||||
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
|
||||
use Stancl\Tenancy\Database\DatabaseManager;
|
||||
use Stancl\Tenancy\Events\Contracts\TenantEvent;
|
||||
use Stancl\Tenancy\Events\Contracts\TenancyEvent;
|
||||
|
||||
class CreateTenantConnection
|
||||
{
|
||||
|
|
@ -15,11 +15,12 @@ class CreateTenantConnection
|
|||
) {
|
||||
}
|
||||
|
||||
public function handle(TenantEvent $event): void
|
||||
public function handle(TenancyEvent $event): void
|
||||
{
|
||||
/** @var TenantWithDatabase */
|
||||
$tenant = $event->tenant;
|
||||
/** @var TenantWithDatabase $tenant */
|
||||
$tenant = $event->tenancy->tenant;
|
||||
|
||||
$this->database->purgeTenantConnection();
|
||||
$this->database->createTenantConnection($tenant);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
src/Listeners/CreateTenantStorage.php
Normal file
18
src/Listeners/CreateTenantStorage.php
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,9 @@ class DeleteTenantStorage
|
|||
{
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
21
src/Listeners/UseCentralConnection.php
Normal file
21
src/Listeners/UseCentralConnection.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Listeners;
|
||||
|
||||
use Stancl\Tenancy\Database\DatabaseManager;
|
||||
use Stancl\Tenancy\Events\Contracts\TenancyEvent;
|
||||
|
||||
class UseCentralConnection
|
||||
{
|
||||
public function __construct(
|
||||
protected DatabaseManager $database,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(TenancyEvent $event): void
|
||||
{
|
||||
$this->database->reconnectToCentral();
|
||||
}
|
||||
}
|
||||
21
src/Listeners/UseTenantConnection.php
Normal file
21
src/Listeners/UseTenantConnection.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Listeners;
|
||||
|
||||
use Stancl\Tenancy\Database\DatabaseManager;
|
||||
use Stancl\Tenancy\Events\Contracts\TenancyEvent;
|
||||
|
||||
class UseTenantConnection
|
||||
{
|
||||
public function __construct(
|
||||
protected DatabaseManager $database,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(TenancyEvent $event): void
|
||||
{
|
||||
$this->database->setDefaultConnection('tenant');
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,11 @@ class InitializeTenancyByDomain extends IdentificationMiddleware
|
|||
/** @return \Illuminate\Http\Response|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(
|
||||
$request,
|
||||
$next,
|
||||
|
|
|
|||
|
|
@ -28,14 +28,13 @@ class InitializeTenancyByPath extends IdentificationMiddleware
|
|||
/** @return \Illuminate\Http\Response|mixed */
|
||||
public function handle(Request $request, Closure $next): mixed
|
||||
{
|
||||
/** @var Route $route */
|
||||
$route = $request->route();
|
||||
$route = $this->route($request);
|
||||
|
||||
// Only initialize tenancy if tenant is the first parameter
|
||||
// We don't want to initialize tenancy if the tenant is
|
||||
// simply injected into some route controller action.
|
||||
if ($route->parameterNames()[0] === PathTenantResolver::tenantParameterName()) {
|
||||
$this->setDefaultTenantForRouteParametersWhenTenancyIsInitialized();
|
||||
$this->setDefaultTenantForRouteParametersWhenInitializingTenancy();
|
||||
|
||||
return $this->initializeTenancy(
|
||||
$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) {
|
||||
/** @var Tenant $tenant */
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ class InitializeTenancyBySubdomain extends InitializeTenancyByDomain
|
|||
/** @return Response|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());
|
||||
|
||||
if (is_object($subdomain) && $subdomain instanceof Exception) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
61
src/Middleware/PreventAccessFromUnwantedDomains.php
Normal file
61
src/Middleware/PreventAccessFromUnwantedDomains.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -27,9 +27,7 @@ abstract class CachedTenantResolver implements TenantResolver
|
|||
|
||||
$key = $this->getCacheKey(...$args);
|
||||
|
||||
if ($this->cache->has($key)) {
|
||||
$tenant = $this->cache->get($key);
|
||||
|
||||
if ($tenant = $this->cache->get($key)) {
|
||||
$this->resolved($tenant, ...$args);
|
||||
|
||||
return $tenant;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace Stancl\Tenancy\Resolvers;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Stancl\Tenancy\Contracts\Domain;
|
||||
use Stancl\Tenancy\Contracts\Tenant;
|
||||
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
|
||||
|
|
@ -39,14 +40,16 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver
|
|||
|
||||
protected function setCurrentDomain(Tenant $tenant, string $domain): void
|
||||
{
|
||||
/** @var Tenant&Model $tenant */
|
||||
static::$currentDomain = $tenant->domains->where('domain', $domain)->first();
|
||||
}
|
||||
|
||||
public function getArgsForTenant(Tenant $tenant): array
|
||||
{
|
||||
/** @var Tenant&Model $tenant */
|
||||
$tenant->unsetRelation('domains');
|
||||
|
||||
return $tenant->domains->map(function (Domain $domain) {
|
||||
return $tenant->domains->map(function (Domain&Model $domain) {
|
||||
return [$domain->domain];
|
||||
})->toArray();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,21 +8,18 @@ use Closure;
|
|||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Traits\Macroable;
|
||||
use Stancl\Tenancy\Concerns\Debuggable;
|
||||
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
|
||||
use Stancl\Tenancy\Contracts\Tenant;
|
||||
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByIdException;
|
||||
|
||||
class Tenancy
|
||||
{
|
||||
use Macroable, Debuggable;
|
||||
use Macroable;
|
||||
|
||||
/**
|
||||
* The current tenant.
|
||||
*
|
||||
* @var (Tenant&Model)|null
|
||||
*/
|
||||
public ?Tenant $tenant = null;
|
||||
public Tenant|null $tenant = null;
|
||||
|
||||
// todo docblock
|
||||
public ?Closure $getBootstrappersUsing = null;
|
||||
|
|
@ -97,9 +94,9 @@ class Tenancy
|
|||
|
||||
public static function model(): Tenant&Model
|
||||
{
|
||||
/** @var class-string<Tenant&Model> $class */
|
||||
$class = config('tenancy.models.tenant');
|
||||
|
||||
/** @var Tenant&Model $model */
|
||||
$model = new $class;
|
||||
|
||||
return $model;
|
||||
|
|
@ -113,8 +110,6 @@ class Tenancy
|
|||
|
||||
/**
|
||||
* Try to find a tenant using an ID.
|
||||
*
|
||||
* @return (Tenant&Model)|null
|
||||
*/
|
||||
public static function find(int|string $id): Tenant|null
|
||||
{
|
||||
|
|
|
|||
65
src/TenancyBroadcastManager.php
Normal file
65
src/TenancyBroadcastManager.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy; // todo new Overrides namespace?
|
||||
|
||||
use Illuminate\Broadcasting\Broadcasters\Broadcaster;
|
||||
use Illuminate\Broadcasting\BroadcastManager;
|
||||
use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract;
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
|
||||
class TenancyBroadcastManager extends BroadcastManager
|
||||
{
|
||||
/**
|
||||
* Names of broadcasters to always recreate using $this->resolve() (even when they're
|
||||
* cached and available in the $broadcasters property).
|
||||
*
|
||||
* The reason for recreating the broadcasters is
|
||||
* to make your app use the correct broadcaster credentials when tenancy is initialized.
|
||||
*/
|
||||
public static array $tenantBroadcasters = ['pusher', 'ably'];
|
||||
|
||||
/**
|
||||
* Override the get method so that the broadcasters in $tenantBroadcasters
|
||||
* always get freshly resolved even when they're cached and available in the $broadcasters property,
|
||||
* and that the resolved broadcaster will override the BroadcasterContract::class singleton.
|
||||
*
|
||||
* If there's a cached broadcaster with the same name as $name,
|
||||
* give its channels to the newly resolved bootstrapper.
|
||||
*/
|
||||
protected function get($name)
|
||||
{
|
||||
if (in_array($name, static::$tenantBroadcasters)) {
|
||||
/** @var Broadcaster|null $originalBroadcaster */
|
||||
$originalBroadcaster = $this->app->make(BroadcasterContract::class);
|
||||
$newBroadcaster = $this->resolve($name);
|
||||
|
||||
// If there is a current broadcaster, give its channels to the newly resolved one
|
||||
// Broadcasters only have to implement the Illuminate\Contracts\Broadcasting\Broadcaster contract
|
||||
// Which doesn't require the channels property
|
||||
// So passing the channels is only needed for Illuminate\Broadcasting\Broadcasters\Broadcaster instances
|
||||
if ($originalBroadcaster instanceof Broadcaster && $newBroadcaster instanceof Broadcaster) {
|
||||
$this->passChannelsFromOriginalBroadcaster($originalBroadcaster, $newBroadcaster);
|
||||
}
|
||||
|
||||
$this->app->singleton(BroadcasterContract::class, fn (Application $app) => $newBroadcaster);
|
||||
|
||||
return $newBroadcaster;
|
||||
}
|
||||
|
||||
return parent::get($name);
|
||||
}
|
||||
|
||||
// Because, unlike the original broadcaster, the newly resolved broadcaster won't have the channels registered using routes/channels.php
|
||||
// Using it for broadcasting won't work, unless we make it have the original broadcaster's channels
|
||||
protected function passChannelsFromOriginalBroadcaster(Broadcaster $originalBroadcaster, Broadcaster $newBroadcaster): void
|
||||
{
|
||||
// invade() because channels can't be retrieved through any of the broadcaster's public methods
|
||||
$originalBroadcaster = invade($originalBroadcaster);
|
||||
|
||||
foreach ($originalBroadcaster->channels as $channel => $callback) {
|
||||
$newBroadcaster->channel($channel, $callback, $originalBroadcaster->retrieveChannelOptions($channel));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,13 +6,10 @@ namespace Stancl\Tenancy;
|
|||
|
||||
use Illuminate\Cache\CacheManager;
|
||||
use Illuminate\Database\Console\Migrations\FreshCommand;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Contracts\Domain;
|
||||
use Stancl\Tenancy\Contracts\Tenant;
|
||||
use Stancl\Tenancy\Enums\LogMode;
|
||||
use Stancl\Tenancy\Events\Contracts\TenancyEvent;
|
||||
use Stancl\Tenancy\Resolvers\DomainTenantResolver;
|
||||
|
||||
class TenancyServiceProvider extends ServiceProvider
|
||||
|
|
@ -62,6 +59,7 @@ class TenancyServiceProvider extends ServiceProvider
|
|||
$this->app->singleton(Commands\Rollback::class, function ($app) {
|
||||
return new Commands\Rollback($app['migrator']);
|
||||
});
|
||||
|
||||
$this->app->singleton(Commands\Seed::class, function ($app) {
|
||||
return new Commands\Seed($app['db']);
|
||||
});
|
||||
|
|
@ -106,6 +104,10 @@ class TenancyServiceProvider extends ServiceProvider
|
|||
__DIR__ . '/../assets/impersonation-migrations/' => database_path('migrations'),
|
||||
], 'impersonation-migrations');
|
||||
|
||||
$this->publishes([
|
||||
__DIR__ . '/../assets/resource-syncing-migrations/' => database_path('migrations'),
|
||||
], 'resource-syncing-migrations');
|
||||
|
||||
$this->publishes([
|
||||
__DIR__ . '/../assets/tenant_routes.stub.php' => base_path('routes/tenant.php'),
|
||||
], 'routes');
|
||||
|
|
@ -118,18 +120,6 @@ class TenancyServiceProvider extends ServiceProvider
|
|||
$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) {
|
||||
if ($app->bound(FilesystemTenancyBootstrapper::class)) {
|
||||
$instance = clone $app['url'];
|
||||
|
|
|
|||
22
src/Vite.php
Normal file
22
src/Vite.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy;
|
||||
|
||||
use Illuminate\Foundation\Vite as BaseVite;
|
||||
|
||||
class Vite extends BaseVite // todo move to a different directory in v4
|
||||
{
|
||||
/**
|
||||
* Generate an asset path for the application.
|
||||
*
|
||||
* @param string $path
|
||||
* @param bool|null $secure
|
||||
* @return string
|
||||
*/
|
||||
protected function assetPath($path, $secure = null)
|
||||
{
|
||||
return global_asset($path);
|
||||
}
|
||||
}
|
||||
|
|
@ -50,6 +50,8 @@ test('context is switched when tenancy is reinitialized', function () {
|
|||
});
|
||||
|
||||
test('central helper runs callbacks in the central state', function () {
|
||||
withTenantDatabases();
|
||||
|
||||
tenancy()->initialize($tenant = Tenant::create());
|
||||
|
||||
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 () {
|
||||
withTenantDatabases();
|
||||
|
||||
tenancy()->initialize(Tenant::create());
|
||||
|
||||
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 () {
|
||||
withTenantDatabases();
|
||||
|
||||
tenancy()->initialize($tenant = Tenant::create());
|
||||
|
||||
tenancy()->central(function () {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ beforeEach(function () {
|
|||
});
|
||||
|
||||
test('batch repository is set to tenant connection and reverted', function () {
|
||||
withTenantDatabases();
|
||||
|
||||
$tenant = Tenant::create();
|
||||
$tenant2 = Tenant::create();
|
||||
|
||||
|
|
|
|||
|
|
@ -4,28 +4,37 @@ declare(strict_types=1);
|
|||
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Stancl\JobPipeline\JobPipeline;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Stancl\Tenancy\Events\TenancyEnded;
|
||||
use Stancl\Tenancy\Jobs\CreateDatabase;
|
||||
use Stancl\Tenancy\Events\TenantCreated;
|
||||
use Stancl\Tenancy\Events\TenantDeleted;
|
||||
use Stancl\Tenancy\Events\DeletingTenant;
|
||||
use Stancl\Tenancy\TenancyBroadcastManager;
|
||||
use Illuminate\Filesystem\FilesystemAdapter;
|
||||
use Illuminate\Broadcasting\BroadcastManager;
|
||||
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||
use Stancl\Tenancy\Jobs\CreateStorageSymlinks;
|
||||
use Stancl\Tenancy\Jobs\RemoveStorageSymlinks;
|
||||
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||
use Stancl\Tenancy\Tests\Etc\TestingBroadcaster;
|
||||
use Stancl\Tenancy\Listeners\DeleteTenantStorage;
|
||||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
use Stancl\Tenancy\Bootstrappers\UrlTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\MailTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain;
|
||||
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\BroadcastTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
|
||||
|
||||
beforeEach(function () {
|
||||
|
|
@ -326,20 +335,176 @@ test('local storage public urls are generated correctly', function() {
|
|||
expect(File::isDirectory($tenantStoragePath))->toBeFalse();
|
||||
});
|
||||
|
||||
test('BroadcastTenancyBootstrapper binds TenancyBroadcastManager to BroadcastManager and reverts the binding when tenancy is ended', function() {
|
||||
expect(app(BroadcastManager::class))->toBeInstanceOf(BroadcastManager::class);
|
||||
|
||||
tenancy()->initialize(Tenant::create());
|
||||
|
||||
expect(app(BroadcastManager::class))->toBeInstanceOf(TenancyBroadcastManager::class);
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
expect(app(BroadcastManager::class))->toBeInstanceOf(BroadcastManager::class);
|
||||
});
|
||||
|
||||
test('BroadcastTenancyBootstrapper maps tenant broadcaster credentials to config as specified in the $credentialsMap property and reverts the config after ending tenancy', function() {
|
||||
config([
|
||||
'broadcasting.connections.testing.driver' => 'testing',
|
||||
'broadcasting.connections.testing.message' => $defaultMessage = 'default',
|
||||
]);
|
||||
|
||||
BroadcastTenancyBootstrapper::$credentialsMap = [
|
||||
'broadcasting.connections.testing.message' => 'testing_broadcaster_message',
|
||||
];
|
||||
|
||||
$tenant = Tenant::create(['testing_broadcaster_message' => $tenantMessage = 'first testing']);
|
||||
$tenant2 = Tenant::create(['testing_broadcaster_message' => $secondTenantMessage = 'second testing']);
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
expect(array_key_exists('testing_broadcaster_message', tenant()->getAttributes()))->toBeTrue();
|
||||
expect(config('broadcasting.connections.testing.message'))->toBe($tenantMessage);
|
||||
|
||||
tenancy()->initialize($tenant2);
|
||||
|
||||
expect(config('broadcasting.connections.testing.message'))->toBe($secondTenantMessage);
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
expect(config('broadcasting.connections.testing.message'))->toBe($defaultMessage);
|
||||
});
|
||||
|
||||
test('BroadcastTenancyBootstrapper makes the app use broadcasters with the correct credentials', function() {
|
||||
config([
|
||||
'broadcasting.default' => 'testing',
|
||||
'broadcasting.connections.testing.driver' => 'testing',
|
||||
'broadcasting.connections.testing.message' => $defaultMessage = 'default',
|
||||
]);
|
||||
|
||||
TenancyBroadcastManager::$tenantBroadcasters[] = 'testing';
|
||||
BroadcastTenancyBootstrapper::$credentialsMap = [
|
||||
'broadcasting.connections.testing.message' => 'testing_broadcaster_message',
|
||||
];
|
||||
|
||||
$registerTestingBroadcaster = fn() => app(BroadcastManager::class)->extend('testing', fn($app, $config) => new TestingBroadcaster($config['message']));
|
||||
|
||||
$registerTestingBroadcaster();
|
||||
|
||||
expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($defaultMessage);
|
||||
|
||||
$tenant = Tenant::create(['testing_broadcaster_message' => $tenantMessage = 'first testing']);
|
||||
$tenant2 = Tenant::create(['testing_broadcaster_message' => $secondTenantMessage = 'second testing']);
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
$registerTestingBroadcaster();
|
||||
|
||||
expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($tenantMessage);
|
||||
|
||||
tenancy()->initialize($tenant2);
|
||||
$registerTestingBroadcaster();
|
||||
|
||||
expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($secondTenantMessage);
|
||||
|
||||
tenancy()->end();
|
||||
$registerTestingBroadcaster();
|
||||
|
||||
expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($defaultMessage);
|
||||
});
|
||||
|
||||
test('MailTenancyBootstrapper maps tenant mail credentials to config as specified in the $credentialsMap property and makes the mailer use tenant credentials', function() {
|
||||
MailTenancyBootstrapper::$credentialsMap = [
|
||||
'mail.mailers.smtp.username' => 'smtp_username',
|
||||
'mail.mailers.smtp.password' => 'smtp_password'
|
||||
];
|
||||
|
||||
config([
|
||||
'mail.default' => 'smtp',
|
||||
'mail.mailers.smtp.username' => $defaultUsername = 'default username',
|
||||
'mail.mailers.smtp.password' => 'no password'
|
||||
]);
|
||||
|
||||
$tenant = Tenant::create(['smtp_password' => $password = 'testing password']);
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
expect(array_key_exists('smtp_password', tenant()->getAttributes()))->toBeTrue();
|
||||
expect(array_key_exists('smtp_host', tenant()->getAttributes()))->toBeFalse();
|
||||
expect(config('mail.mailers.smtp.username'))->toBe($defaultUsername);
|
||||
expect(config('mail.mailers.smtp.password'))->toBe(tenant()->smtp_password);
|
||||
|
||||
// Assert that the current mailer uses tenant's smtp_password
|
||||
assertMailerTransportUsesPassword($password);
|
||||
});
|
||||
|
||||
test('MailTenancyBootstrapper reverts the config and mailer credentials to default when tenancy ends', function() {
|
||||
MailTenancyBootstrapper::$credentialsMap = ['mail.mailers.smtp.password' => 'smtp_password'];
|
||||
config(['mail.default' => 'smtp', 'mail.mailers.smtp.password' => $defaultPassword = 'no password']);
|
||||
|
||||
tenancy()->initialize(Tenant::create(['smtp_password' => $tenantPassword = 'testing password']));
|
||||
|
||||
expect(config('mail.mailers.smtp.password'))->toBe($tenantPassword);
|
||||
|
||||
assertMailerTransportUsesPassword($tenantPassword);
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
expect(config('mail.mailers.smtp.password'))->toBe($defaultPassword);
|
||||
|
||||
// Assert that the current mailer uses the default SMTP password
|
||||
assertMailerTransportUsesPassword($defaultPassword);
|
||||
});
|
||||
|
||||
function getDiskPrefix(string $disk): string
|
||||
{
|
||||
/** @var FilesystemAdapter $disk */
|
||||
$disk = Storage::disk($disk);
|
||||
$adapter = $disk->getAdapter();
|
||||
$prefix = invade(invade($adapter)->prefixer)->prefix;
|
||||
|
||||
$prefixer = (new ReflectionObject($adapter))->getProperty('prefixer');
|
||||
$prefixer->setAccessible(true);
|
||||
|
||||
// reflection -> instance
|
||||
$prefixer = $prefixer->getValue($adapter);
|
||||
|
||||
$prefix = (new ReflectionProperty($prefixer, 'prefix'));
|
||||
$prefix->setAccessible(true);
|
||||
|
||||
return $prefix->getValue($prefixer);
|
||||
return $prefix;
|
||||
}
|
||||
|
||||
test('url bootstrapper overrides the root url when tenancy gets initialized and reverts the url to the central one after tenancy ends', function() {
|
||||
config(['tenancy.bootstrappers.url' => UrlTenancyBootstrapper::class]);
|
||||
|
||||
Route::group([
|
||||
'middleware' => InitializeTenancyBySubdomain::class,
|
||||
], function () {
|
||||
Route::get('/', function () {
|
||||
return true;
|
||||
})->name('home');
|
||||
});
|
||||
|
||||
$baseUrl = url(route('home'));
|
||||
config(['app.url' => $baseUrl]);
|
||||
|
||||
$rootUrlOverride = function (Tenant $tenant) use ($baseUrl) {
|
||||
$scheme = str($baseUrl)->before('://');
|
||||
$hostname = str($baseUrl)->after($scheme . '://');
|
||||
|
||||
return $scheme . '://' . $tenant->getTenantKey() . '.' . $hostname;
|
||||
};
|
||||
|
||||
UrlTenancyBootstrapper::$rootUrlOverride = $rootUrlOverride;
|
||||
|
||||
$tenant = Tenant::create();
|
||||
$tenantUrl = $rootUrlOverride($tenant);
|
||||
|
||||
expect($tenantUrl)->not()->toBe($baseUrl);
|
||||
|
||||
expect(url(route('home')))->toBe($baseUrl);
|
||||
expect(URL::to('/'))->toBe($baseUrl);
|
||||
expect(config('app.url'))->toBe($baseUrl);
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
expect(url(route('home')))->toBe($tenantUrl);
|
||||
expect(URL::to('/'))->toBe($tenantUrl);
|
||||
expect(config('app.url'))->toBe($tenantUrl);
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
expect(url(route('home')))->toBe($baseUrl);
|
||||
expect(URL::to('/'))->toBe($baseUrl);
|
||||
expect(config('app.url'))->toBe($baseUrl);
|
||||
});
|
||||
|
|
|
|||
65
tests/BroadcastingTest.php
Normal file
65
tests/BroadcastingTest.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Stancl\Tenancy\Events\TenancyEnded;
|
||||
use Illuminate\Support\Facades\Broadcast;
|
||||
use Stancl\Tenancy\TenancyBroadcastManager;
|
||||
use Illuminate\Broadcasting\BroadcastManager;
|
||||
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||
use Stancl\Tenancy\Tests\Etc\TestingBroadcaster;
|
||||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract;
|
||||
|
||||
beforeEach(function() {
|
||||
withTenantDatabases();
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||
});
|
||||
|
||||
test('bound broadcaster instance is the same before initializing tenancy and after ending it', function() {
|
||||
config(['broadcasting.default' => 'null']);
|
||||
TenancyBroadcastManager::$tenantBroadcasters[] = 'null';
|
||||
|
||||
$originalBroadcaster = app(BroadcasterContract::class);
|
||||
|
||||
tenancy()->initialize(Tenant::create());
|
||||
|
||||
// TenancyBroadcastManager binds new broadcaster
|
||||
$tenantBroadcaster = app(BroadcastManager::class)->driver();
|
||||
|
||||
expect($tenantBroadcaster)->not()->toBe($originalBroadcaster);
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
expect($originalBroadcaster)->toBe(app(BroadcasterContract::class));
|
||||
});
|
||||
|
||||
test('new broadcasters get the channels from the previously bound broadcaster', function() {
|
||||
config([
|
||||
'broadcasting.default' => $driver = 'testing',
|
||||
'broadcasting.connections.testing.driver' => $driver,
|
||||
]);
|
||||
|
||||
TenancyBroadcastManager::$tenantBroadcasters[] = $driver;
|
||||
|
||||
$registerTestingBroadcaster = fn() => app(BroadcastManager::class)->extend('testing', fn($app, $config) => new TestingBroadcaster('testing'));
|
||||
$getCurrentChannels = fn() => array_keys(invade(app(BroadcastManager::class)->driver())->channels);
|
||||
|
||||
$registerTestingBroadcaster();
|
||||
Broadcast::channel($channel = 'testing-channel', fn() => true);
|
||||
|
||||
expect($channel)->toBeIn($getCurrentChannels());
|
||||
|
||||
tenancy()->initialize(Tenant::create());
|
||||
$registerTestingBroadcaster();
|
||||
|
||||
expect($channel)->toBeIn($getCurrentChannels());
|
||||
|
||||
tenancy()->end();
|
||||
$registerTestingBroadcaster();
|
||||
|
||||
expect($channel)->toBeIn($getCurrentChannels());
|
||||
});
|
||||
|
|
@ -18,14 +18,16 @@ use Stancl\Tenancy\Events\TenantCreated;
|
|||
use Stancl\Tenancy\Events\TenantDeleted;
|
||||
use Stancl\Tenancy\Tests\Etc\TestSeeder;
|
||||
use Stancl\Tenancy\Events\DeletingTenant;
|
||||
use Stancl\Tenancy\Events\DatabaseMigrated;
|
||||
use Stancl\Tenancy\Tests\Etc\ExampleSeeder;
|
||||
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException;
|
||||
|
||||
beforeEach(function () {
|
||||
if (file_exists($schemaPath = database_path('schema/tenant-schema.dump'))) {
|
||||
if (file_exists($schemaPath = 'tests/Etc/tenant-schema-test.dump')) {
|
||||
unlink($schemaPath);
|
||||
}
|
||||
|
||||
|
|
@ -109,30 +111,86 @@ test('migrate command loads schema state', function () {
|
|||
expect(Schema::hasTable('users'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('dump command works', function () {
|
||||
$tenant = Tenant::create();
|
||||
Artisan::call('tenants:migrate');
|
||||
test('migrate command only throws exceptions if skip-failing is not passed', function() {
|
||||
Tenant::create();
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
$tenantWithoutDatabase = Tenant::create();
|
||||
$databaseToDrop = $tenantWithoutDatabase->run(fn() => DB::connection()->getDatabaseName());
|
||||
|
||||
Artisan::call('tenants:dump --path="tests/Etc/tenant-schema-test.dump"');
|
||||
expect('tests/Etc/tenant-schema-test.dump')->toBeFile();
|
||||
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('tenant dump file gets created as tenant-schema.dump in the database schema folder by default', function() {
|
||||
config(['tenancy.migration_parameters.--schema-path' => $schemaPath = database_path('schema/tenant-schema.dump')]);
|
||||
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 () {
|
||||
$tenant = Tenant::create();
|
||||
$schemaPath = 'tests/Etc/tenant-schema-test.dump';
|
||||
|
||||
Artisan::call('tenants:migrate');
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
expect($schemaPath)->not()->toBeFile();
|
||||
|
||||
Artisan::call('tenants:dump');
|
||||
Artisan::call('tenants:dump ' . "--tenant='$tenant->id' --path='$schemaPath'");
|
||||
|
||||
expect($schemaPath)->toBeFile();
|
||||
});
|
||||
|
||||
test('migrate command uses the correct schema path by default', function () {
|
||||
test('dump command generates dump at the passed path', function() {
|
||||
$tenant = Tenant::create();
|
||||
|
||||
Artisan::call('tenants:migrate');
|
||||
|
||||
expect($schemaPath = 'tests/Etc/tenant-schema-test.dump')->not()->toBeFile();
|
||||
|
||||
Artisan::call("tenants:dump --tenant='$tenant->id' --path='$schemaPath'");
|
||||
|
||||
expect($schemaPath)->toBeFile();
|
||||
});
|
||||
|
||||
test('dump command generates dump at the path specified in the tenancy migration parameters config', function() {
|
||||
config(['tenancy.migration_parameters.--schema-path' => $schemaPath = 'tests/Etc/tenant-schema-test.dump']);
|
||||
|
||||
$tenant = Tenant::create();
|
||||
|
||||
Artisan::call('tenants:migrate');
|
||||
|
||||
expect($schemaPath)->not()->toBeFile();
|
||||
|
||||
Artisan::call("tenants:dump --tenant='$tenant->id'");
|
||||
|
||||
expect($schemaPath)->toBeFile();
|
||||
});
|
||||
|
||||
test('migrate command correctly uses the schema dump located at the configured schema path by default', function () {
|
||||
config(['tenancy.migration_parameters.--schema-path' => 'tests/Etc/tenant-schema.dump']);
|
||||
$tenant = Tenant::create();
|
||||
|
||||
|
|
@ -146,6 +204,7 @@ test('migrate command uses the correct schema path by default', function () {
|
|||
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
// schema_users is a table included in the tests/Etc/tenant-schema dump
|
||||
// Check for both tables to see if missing migrations also get executed
|
||||
expect(Schema::hasTable('schema_users'))->toBeTrue();
|
||||
expect(Schema::hasTable('users'))->toBeTrue();
|
||||
|
|
@ -163,17 +222,17 @@ test('rollback command works', function () {
|
|||
expect(Schema::hasTable('users'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('seed command works', function (){
|
||||
test('seed command works', function () {
|
||||
$tenant = Tenant::create();
|
||||
Artisan::call('tenants:migrate');
|
||||
|
||||
$tenant->run(function (){
|
||||
$tenant->run(function () {
|
||||
expect(DB::table('users')->count())->toBe(0);
|
||||
});
|
||||
|
||||
Artisan::call('tenants:seed', ['--class' => TestSeeder::class]);
|
||||
|
||||
$tenant->run(function (){
|
||||
$tenant->run(function () {
|
||||
$user = DB::table('users');
|
||||
expect($user->count())->toBe(1)
|
||||
->and($user->first()->email)->toBe('seeded@user');
|
||||
|
|
@ -355,7 +414,7 @@ function runCommandWorks(): void
|
|||
Artisan::call('tenants:migrate', ['--tenants' => [$id]]);
|
||||
|
||||
pest()->artisan("tenants:run --tenants=$id 'foo foo --b=bar --c=xyz' ")
|
||||
->expectsOutput("User's name is Test command")
|
||||
->expectsOutput("User's name is Test user")
|
||||
->expectsOutput('foo')
|
||||
->expectsOutput('xyz');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ beforeEach(function () {
|
|||
config(['tenancy.models.tenant' => DatabaseAndDomainTenant::class]);
|
||||
});
|
||||
|
||||
test('job delete domains successfully', function (){
|
||||
test('job delete domains successfully', function () {
|
||||
$tenant = DatabaseAndDomainTenant::create();
|
||||
|
||||
$tenant->domains()->create([
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ use Stancl\Tenancy\Database\Models;
|
|||
use Stancl\Tenancy\Database\Models\Domain;
|
||||
use Stancl\Tenancy\Exceptions\DomainOccupiedByOtherTenantException;
|
||||
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
|
||||
use Stancl\Tenancy\Features\UniversalRoutes;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
|
||||
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
|
||||
Route::middlewareGroup('universal', []);
|
||||
config(['tenancy.features' => [UniversalRoutes::class]]);
|
||||
|
||||
$this->withoutExceptionHandling()->get('http://foo.localhost/foo/abc/xyz');
|
||||
})->throws(TenantCouldNotBeIdentifiedOnDomainException::class);;
|
||||
|
|
|
|||
104
tests/EarlyIdentificationTest.php
Normal file
104
tests/EarlyIdentificationTest.php
Normal 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
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Stancl\Tenancy\Tests\Etc\Console;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ExampleCommand extends Command
|
||||
|
|
@ -22,14 +23,13 @@ class ExampleCommand extends Command
|
|||
*/
|
||||
public function handle()
|
||||
{
|
||||
User::create([
|
||||
'id' => 999,
|
||||
'name' => 'Test command',
|
||||
'email' => 'test@command.com',
|
||||
$id = User::create([
|
||||
'name' => 'Test user',
|
||||
'email' => Str::random(8) . '@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
])->id;
|
||||
|
||||
$this->line("User's name is " . User::find(999)->name);
|
||||
$this->line("User's name is " . User::find($id)->name);
|
||||
$this->line($this->argument('a'));
|
||||
$this->line($this->option('c'));
|
||||
}
|
||||
|
|
|
|||
16
tests/Etc/EarlyIdentification/AdditionalMiddleware.php
Normal file
16
tests/Etc/EarlyIdentification/AdditionalMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
19
tests/Etc/EarlyIdentification/Controller.php
Normal file
19
tests/Etc/EarlyIdentification/Controller.php
Normal 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;
|
||||
}
|
||||
}
|
||||
15
tests/Etc/EarlyIdentification/Service.php
Normal file
15
tests/Etc/EarlyIdentification/Service.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ class HttpKernel extends Kernel
|
|||
*/
|
||||
protected $middlewareGroups = [
|
||||
'web' => [
|
||||
\Orchestra\Testbench\Http\Middleware\EncryptCookies::class,
|
||||
\Illuminate\Cookie\Middleware\EncryptCookies::class,
|
||||
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
||||
\Illuminate\Session\Middleware\StartSession::class,
|
||||
// \Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||
|
|
|
|||
25
tests/Etc/TestingBroadcaster.php
Normal file
25
tests/Etc/TestingBroadcaster.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace Stancl\Tenancy\Tests\Etc;
|
||||
|
||||
use Illuminate\Broadcasting\Broadcasters\Broadcaster;
|
||||
|
||||
class TestingBroadcaster extends Broadcaster {
|
||||
public function __construct(
|
||||
public string $message
|
||||
) {}
|
||||
|
||||
public function auth($request)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function validAuthenticationResponse($request, $result)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function broadcast(array $channels, $event, array $payload = [])
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class TestCreateCompaniesTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::create('companies', function (Blueprint $table) {
|
||||
$table->increments('id');
|
||||
$table->string('global_id')->unique();
|
||||
$table->string('name');
|
||||
$table->string('email');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('companies');
|
||||
}
|
||||
}
|
||||
|
|
@ -79,7 +79,7 @@ test('ing events can be used to cancel db creation', function () {
|
|||
});
|
||||
|
||||
$tenant = Tenant::create();
|
||||
dispatch_now(new CreateDatabase($tenant));
|
||||
dispatch_sync(new CreateDatabase($tenant));
|
||||
|
||||
pest()->assertFalse($tenant->database()->manager()->databaseExists(
|
||||
$tenant->database()->getName()
|
||||
|
|
@ -171,12 +171,13 @@ test('database is not migrated if creation is disabled', function () {
|
|||
})->toListener()
|
||||
);
|
||||
|
||||
Tenant::create([
|
||||
$tenant = Tenant::create([
|
||||
'tenancy_create_database' => false,
|
||||
'tenancy_db_name' => 'already_created',
|
||||
]);
|
||||
|
||||
expect(pest()->hasFailed())->toBeFalse();
|
||||
// assert test didn't fail
|
||||
$this->assertTrue($tenant->exists());
|
||||
});
|
||||
|
||||
class FooListener extends QueueableListener
|
||||
|
|
|
|||
29
tests/Features/ViteBundlerTest.php
Normal file
29
tests/Features/ViteBundlerTest.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Vite;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use Stancl\Tenancy\Vite as StanclVite;
|
||||
use Stancl\Tenancy\Features\ViteBundler;
|
||||
|
||||
test('vite helper uses our custom class', function() {
|
||||
$vite = app(Vite::class);
|
||||
|
||||
expect($vite)->toBeInstanceOf(Vite::class);
|
||||
expect($vite)->not()->toBeInstanceOf(StanclVite::class);
|
||||
|
||||
config([
|
||||
'tenancy.features' => [ViteBundler::class],
|
||||
]);
|
||||
|
||||
$tenant = Tenant::create();
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
app()->forgetInstance(Vite::class);
|
||||
|
||||
$vite = app(Vite::class);
|
||||
|
||||
expect($vite)->toBeInstanceOf(StanclVite::class);
|
||||
});
|
||||
76
tests/MailTest.php
Normal file
76
tests/MailTest.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Mail\MailManager;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Stancl\Tenancy\Events\TenancyEnded;
|
||||
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
use Stancl\Tenancy\Bootstrappers\MailTenancyBootstrapper;
|
||||
|
||||
beforeEach(function() {
|
||||
config(['mail.default' => 'smtp']);
|
||||
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||
});
|
||||
|
||||
// Initialize tenancy as $tenant and assert that the smtp mailer's transport has the correct password
|
||||
function assertMailerTransportUsesPassword(string|null $password) {
|
||||
$manager = app(MailManager::class);
|
||||
$mailer = invade($manager)->get('smtp');
|
||||
$mailerPassword = invade($mailer->getSymfonyTransport())->password;
|
||||
|
||||
expect($mailerPassword)->toBe((string) $password);
|
||||
};
|
||||
|
||||
test('mailer transport uses the correct credentials', function() {
|
||||
withTenantDatabases();
|
||||
|
||||
config(['mail.default' => 'smtp', 'mail.mailers.smtp.password' => $defaultPassword = 'DEFAULT']);
|
||||
MailTenancyBootstrapper::$credentialsMap = ['mail.mailers.smtp.password' => 'smtp_password'];
|
||||
|
||||
tenancy()->initialize($tenant = Tenant::create());
|
||||
assertMailerTransportUsesPassword($defaultPassword); // $tenant->smtp_password is not set, so the default password should be used
|
||||
tenancy()->end();
|
||||
|
||||
// Assert mailer uses the updated password
|
||||
$tenant->update(['smtp_password' => $newPassword = 'changed']);
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
assertMailerTransportUsesPassword($newPassword);
|
||||
tenancy()->end();
|
||||
|
||||
// Assert mailer uses the correct password after switching to a different tenant
|
||||
tenancy()->initialize(Tenant::create(['smtp_password' => $newTenantPassword = 'updated']));
|
||||
assertMailerTransportUsesPassword($newTenantPassword);
|
||||
tenancy()->end();
|
||||
|
||||
// Assert mailer uses the default password after tenancy ends
|
||||
assertMailerTransportUsesPassword($defaultPassword);
|
||||
});
|
||||
|
||||
|
||||
test('initializing and ending tenancy binds a fresh MailManager instance without cached mailers', function() {
|
||||
withTenantDatabases();
|
||||
|
||||
$mailers = fn() => invade(app(MailManager::class))->mailers;
|
||||
|
||||
app(MailManager::class)->mailer('smtp');
|
||||
|
||||
expect($mailers())->toHaveCount(1);
|
||||
|
||||
tenancy()->initialize(Tenant::create());
|
||||
|
||||
expect($mailers())->toHaveCount(0);
|
||||
|
||||
app(MailManager::class)->mailer('smtp');
|
||||
|
||||
expect($mailers())->toHaveCount(1);
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
expect($mailers())->toHaveCount(0);
|
||||
});
|
||||
45
tests/ManualModeTest.php
Normal file
45
tests/ManualModeTest.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Stancl\JobPipeline\JobPipeline;
|
||||
use Stancl\Tenancy\Events\TenancyEnded;
|
||||
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||
use Stancl\Tenancy\Events\TenantCreated;
|
||||
use Stancl\Tenancy\Jobs\CreateDatabase;
|
||||
use Stancl\Tenancy\Listeners\CreateTenantConnection;
|
||||
use Stancl\Tenancy\Listeners\UseCentralConnection;
|
||||
use Stancl\Tenancy\Listeners\UseTenantConnection;
|
||||
use \Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
|
||||
test('manual tenancy initialization works', function () {
|
||||
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
|
||||
return $event->tenant;
|
||||
})->toListener());
|
||||
|
||||
Event::listen(TenancyInitialized::class, CreateTenantConnection::class);
|
||||
Event::listen(TenancyInitialized::class, UseTenantConnection::class);
|
||||
Event::listen(TenancyEnded::class, UseCentralConnection::class);
|
||||
|
||||
$tenant = Tenant::create();
|
||||
|
||||
expect(app('db')->getDefaultConnection())->toBe('central');
|
||||
expect(array_keys(app('db')->getConnections()))->toBe(['central', 'tenant_host_connection']);
|
||||
pest()->assertArrayNotHasKey('tenant', config('database.connections'));
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
// Trigger creation of the tenant connection
|
||||
createUsersTable();
|
||||
|
||||
expect(app('db')->getDefaultConnection())->toBe('tenant');
|
||||
expect(array_keys(app('db')->getConnections()))->toBe(['central', 'tenant']);
|
||||
pest()->assertArrayHasKey('tenant', config('database.connections'));
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
expect(array_keys(app('db')->getConnections()))->toBe(['central']);
|
||||
expect(config('database.connections.tenant'))->toBeNull();
|
||||
expect(app('db')->getDefaultConnection())->toBe(config('tenancy.database.central_connection'));
|
||||
});
|
||||
|
|
@ -67,23 +67,6 @@ test('CreatePendingTenants command cannot run with both time constraints', funct
|
|||
->assertFailed();
|
||||
});
|
||||
|
||||
test('CreatePendingTenants commands all option overrides any config constraints', function () {
|
||||
Tenant::createPending();
|
||||
Tenant::createPending();
|
||||
|
||||
tenancy()->model()->query()->onlyPending()->first()->update([
|
||||
'pending_since' => now()->subDays(10)
|
||||
]);
|
||||
|
||||
config(['tenancy.pending.older_than_days' => 4]);
|
||||
|
||||
Artisan::call(ClearPendingTenants::class, [
|
||||
'--all' => true
|
||||
]);
|
||||
|
||||
expect(Tenant::onlyPending()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('tenancy can check if there are any pending tenants', function () {
|
||||
expect(Tenant::onlyPending()->exists())->toBeFalse();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
<?php
|
||||
|
||||
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(...filesAndFoldersExcluding(['WithoutTenancy'])); // todo move all tests to a separate folder
|
||||
|
||||
|
|
@ -14,4 +18,9 @@ function filesAndFoldersExcluding(array $exclude = []): array
|
|||
$dirs = scandir(__DIR__);
|
||||
|
||||
return array_filter($dirs, fn($dir) => ! in_array($dir, array_merge(['.', '..'], $exclude) , true));
|
||||
function withTenantDatabases()
|
||||
{
|
||||
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
|
||||
return $event->tenant;
|
||||
})->toListener());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,23 +3,23 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
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 Illuminate\Support\Facades\DB;
|
||||
use Stancl\Tenancy\Tests\Etc\User;
|
||||
use Stancl\JobPipeline\JobPipeline;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Stancl\Tenancy\Events\TenancyEnded;
|
||||
use Stancl\Tenancy\Jobs\CreateDatabase;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Stancl\Tenancy\Events\TenantCreated;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Queue\Events\JobProcessed;
|
||||
use Illuminate\Queue\Events\JobProcessing;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
|
|
@ -48,6 +48,8 @@ afterEach(function () {
|
|||
});
|
||||
|
||||
test('tenant id is passed to tenant queues', function () {
|
||||
withTenantDatabases();
|
||||
|
||||
config(['queue.default' => 'sync']);
|
||||
|
||||
$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 () {
|
||||
withTenantDatabases();
|
||||
|
||||
$tenant = Tenant::create();
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
|
|
@ -156,6 +160,8 @@ test('tenancy is initialized when retrying jobs', function (bool $shouldEndTenan
|
|||
})->with([true, false]);
|
||||
|
||||
test('the tenant used by the job doesnt change when the current tenant changes', function () {
|
||||
withTenantDatabases();
|
||||
|
||||
$tenant1 = Tenant::create([
|
||||
'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
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
|
|
|||
|
|
@ -832,7 +832,7 @@ function migrateUsersTableForTenants(): void
|
|||
// Tenant model used for resource syncing setup
|
||||
class ResourceTenant extends Tenant
|
||||
{
|
||||
public function users()
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(CentralUser::class, 'tenant_users', 'tenant_id', 'global_user_id', 'id', 'global_id')
|
||||
->using(TenantPivot::class);
|
||||
|
|
|
|||
398
tests/ResourceSyncingUsingPolymorphicTest.php
Normal file
398
tests/ResourceSyncingUsingPolymorphicTest.php
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphToMany;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Str;
|
||||
use Stancl\JobPipeline\JobPipeline;
|
||||
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Contracts\Syncable;
|
||||
use Stancl\Tenancy\Contracts\SyncMaster;
|
||||
use Stancl\Tenancy\Database\Concerns\CentralConnection;
|
||||
use Stancl\Tenancy\Database\Concerns\ResourceSyncing;
|
||||
use Stancl\Tenancy\Database\DatabaseConfig;
|
||||
use Stancl\Tenancy\Database\Models\TenantMorphPivot;
|
||||
use Stancl\Tenancy\Database\Models\TenantPivot;
|
||||
use Stancl\Tenancy\Events\SyncedResourceSaved;
|
||||
use Stancl\Tenancy\Events\TenancyEnded;
|
||||
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||
use Stancl\Tenancy\Events\TenantCreated;
|
||||
use Stancl\Tenancy\Jobs\CreateDatabase;
|
||||
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
use Stancl\Tenancy\Listeners\UpdateSyncedResource;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'tenancy.bootstrappers' => [
|
||||
DatabaseTenancyBootstrapper::class,
|
||||
],
|
||||
'tenancy.models.tenant' => ResourceTenantUsingPolymorphic::class,
|
||||
]);
|
||||
|
||||
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
|
||||
return $event->tenant;
|
||||
})->toListener());
|
||||
|
||||
DatabaseConfig::generateDatabaseNamesUsing(function () {
|
||||
return 'db' . Str::random(16);
|
||||
});
|
||||
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||
|
||||
UpdateSyncedResource::$shouldQueue = false; // Global state cleanup
|
||||
Event::listen(SyncedResourceSaved::class, UpdateSyncedResource::class);
|
||||
|
||||
// Run migrations on central connection
|
||||
pest()->artisan('migrate', [
|
||||
'--path' => [
|
||||
__DIR__ . '/../assets/resource-syncing-migrations',
|
||||
__DIR__ . '/Etc/synced_resource_migrations/users',
|
||||
__DIR__ . '/Etc/synced_resource_migrations/companies',
|
||||
],
|
||||
'--realpath' => true,
|
||||
])->assertExitCode(0);
|
||||
});
|
||||
|
||||
test('resource syncing works using a single pivot table for multiple models when syncing from central to tenant', function () {
|
||||
$tenant1 = ResourceTenantUsingPolymorphic::create(['id' => 't1']);
|
||||
migrateUsersTableForTenants();
|
||||
|
||||
$centralUser = CentralUserUsingPolymorphic::create([
|
||||
'global_id' => 'acme',
|
||||
'name' => 'John Doe',
|
||||
'email' => 'john@localhost',
|
||||
'password' => 'password',
|
||||
'role' => 'commenter',
|
||||
]);
|
||||
|
||||
$tenant1->run(function () {
|
||||
expect(TenantUserUsingPolymorphic::all())->toHaveCount(0);
|
||||
});
|
||||
|
||||
$centralUser->tenants()->attach('t1');
|
||||
|
||||
// Assert `tenants` are accessible
|
||||
expect($centralUser->tenants->pluck('id')->toArray())->toBe(['t1']);
|
||||
|
||||
// Users are accessible from tenant
|
||||
expect($tenant1->users()->pluck('email')->toArray())->toBe(['john@localhost']);
|
||||
|
||||
// Assert User resource is synced
|
||||
$tenant1->run(function () use ($centralUser) {
|
||||
$tenantUser = TenantUserUsingPolymorphic::first()->toArray();
|
||||
$centralUser = $centralUser->withoutRelations()->toArray();
|
||||
unset($centralUser['id'], $tenantUser['id']);
|
||||
|
||||
expect($tenantUser)->toBe($centralUser);
|
||||
});
|
||||
|
||||
$tenant2 = ResourceTenantUsingPolymorphic::create(['id' => 't2']);
|
||||
migrateCompaniesTableForTenants();
|
||||
|
||||
$centralCompany = CentralCompanyUsingPolymorphic::create([
|
||||
'global_id' => 'acme',
|
||||
'name' => 'ArchTech',
|
||||
'email' => 'archtech@localhost',
|
||||
]);
|
||||
|
||||
$tenant2->run(function () {
|
||||
expect(TenantCompanyUsingPolymorphic::all())->toHaveCount(0);
|
||||
});
|
||||
|
||||
$centralCompany->tenants()->attach('t2');
|
||||
|
||||
// Assert `tenants` are accessible
|
||||
expect($centralCompany->tenants->pluck('id')->toArray())->toBe(['t2']);
|
||||
|
||||
// Companies are accessible from tenant
|
||||
expect($tenant2->companies()->pluck('email')->toArray())->toBe(['archtech@localhost']);
|
||||
|
||||
// Assert Company resource is synced
|
||||
$tenant2->run(function () use ($centralCompany) {
|
||||
$tenantCompany = TenantCompanyUsingPolymorphic::first()->toArray();
|
||||
$centralCompany = $centralCompany->withoutRelations()->toArray();
|
||||
|
||||
unset($centralCompany['id'], $tenantCompany['id']);
|
||||
|
||||
expect($tenantCompany)->toBe($centralCompany);
|
||||
});
|
||||
});
|
||||
|
||||
test('resource syncing works using a single pivot table for multiple models when syncing from tenant to central', function () {
|
||||
$tenant1 = ResourceTenantUsingPolymorphic::create(['id' => 't1']);
|
||||
migrateUsersTableForTenants();
|
||||
|
||||
tenancy()->initialize($tenant1);
|
||||
|
||||
$tenantUser = TenantUserUsingPolymorphic::create([
|
||||
'name' => 'John Doe',
|
||||
'email' => 'john@localhost',
|
||||
'password' => 'password',
|
||||
'role' => 'commenter',
|
||||
]);
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
// Assert User resource is synced
|
||||
$centralUser = CentralUserUsingPolymorphic::first();
|
||||
|
||||
// Assert `tenants` are accessible
|
||||
expect($centralUser->tenants->pluck('id')->toArray())->toBe(['t1']);
|
||||
|
||||
// Users are accessible from tenant
|
||||
expect($tenant1->users()->pluck('email')->toArray())->toBe(['john@localhost']);
|
||||
|
||||
$centralUser = $centralUser->withoutRelations()->toArray();
|
||||
$tenantUser = $tenantUser->toArray();
|
||||
unset($centralUser['id'], $tenantUser['id']);
|
||||
|
||||
// array keys use a different order here
|
||||
expect($tenantUser)->toEqualCanonicalizing($centralUser);
|
||||
|
||||
$tenant2 = ResourceTenantUsingPolymorphic::create(['id' => 't2']);
|
||||
migrateCompaniesTableForTenants();
|
||||
|
||||
tenancy()->initialize($tenant2);
|
||||
|
||||
$tenantCompany = TenantCompanyUsingPolymorphic::create([
|
||||
'global_id' => 'acme',
|
||||
'name' => 'tenant comp',
|
||||
'email' => 'company@localhost',
|
||||
]);
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
// Assert Company resource is synced
|
||||
$centralCompany = CentralCompanyUsingPolymorphic::first();
|
||||
|
||||
// Assert `tenants` are accessible
|
||||
expect($centralCompany->tenants->pluck('id')->toArray())->toBe(['t2']);
|
||||
|
||||
// Companies are accessible from tenant
|
||||
expect($tenant2->companies()->pluck('email')->toArray())->toBe(['company@localhost']);
|
||||
|
||||
$centralCompany = $centralCompany->withoutRelations()->toArray();
|
||||
$tenantCompany = $tenantCompany->toArray();
|
||||
unset($centralCompany['id'], $tenantCompany['id']);
|
||||
|
||||
expect($tenantCompany)->toBe($centralCompany);
|
||||
});
|
||||
|
||||
test('right resources are accessible from the tenant', function () {
|
||||
$tenant1 = ResourceTenantUsingPolymorphic::create(['id' => 't1']);
|
||||
$tenant2 = ResourceTenantUsingPolymorphic::create(['id' => 't2']);
|
||||
migrateUsersTableForTenants();
|
||||
|
||||
$user1 = CentralUserUsingPolymorphic::create([
|
||||
'global_id' => 'user1',
|
||||
'name' => 'user1',
|
||||
'email' => 'user1@localhost',
|
||||
'password' => 'password',
|
||||
'role' => 'commenter',
|
||||
]);
|
||||
|
||||
$user2 = CentralUserUsingPolymorphic::create([
|
||||
'global_id' => 'user2',
|
||||
'name' => 'user2',
|
||||
'email' => 'user2@localhost',
|
||||
'password' => 'password',
|
||||
'role' => 'commenter',
|
||||
]);
|
||||
|
||||
$user3 = CentralUserUsingPolymorphic::create([
|
||||
'global_id' => 'user3',
|
||||
'name' => 'user3',
|
||||
'email' => 'user3@localhost',
|
||||
'password' => 'password',
|
||||
'role' => 'commenter',
|
||||
]);
|
||||
|
||||
$user1->tenants()->attach('t1');
|
||||
$user2->tenants()->attach('t1');
|
||||
$user3->tenants()->attach('t2');
|
||||
|
||||
expect($tenant1->users()->pluck('email')->toArray())->toBe([$user1->email, $user2->email]);
|
||||
expect($tenant2->users()->pluck('email')->toArray())->toBe([$user3->email]);
|
||||
});
|
||||
|
||||
function migrateCompaniesTableForTenants(): void
|
||||
{
|
||||
pest()->artisan('tenants:migrate', [
|
||||
'--path' => __DIR__ . '/Etc/synced_resource_migrations/companies',
|
||||
'--realpath' => true,
|
||||
])->assertExitCode(0);
|
||||
}
|
||||
|
||||
// Tenant model used for resource syncing setup
|
||||
class ResourceTenantUsingPolymorphic extends Tenant
|
||||
{
|
||||
public function users(): MorphToMany
|
||||
{
|
||||
return $this->morphedByMany(CentralUserUsingPolymorphic::class, 'tenant_resources', 'tenant_resources', 'tenant_id', 'resource_global_id', 'id', 'global_id')
|
||||
->using(TenantMorphPivot::class);
|
||||
}
|
||||
|
||||
public function companies(): MorphToMany
|
||||
{
|
||||
return $this->morphedByMany(CentralCompanyUsingPolymorphic::class, 'tenant_resources', 'tenant_resources', 'tenant_id', 'resource_global_id', 'id', 'global_id')
|
||||
->using(TenantMorphPivot::class);
|
||||
}
|
||||
}
|
||||
|
||||
class CentralUserUsingPolymorphic extends Model implements SyncMaster
|
||||
{
|
||||
use ResourceSyncing, CentralConnection;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
public $table = 'users';
|
||||
|
||||
public function getTenantModelName(): string
|
||||
{
|
||||
return TenantUserUsingPolymorphic::class;
|
||||
}
|
||||
|
||||
public function getGlobalIdentifierKey(): string|int
|
||||
{
|
||||
return $this->getAttribute($this->getGlobalIdentifierKeyName());
|
||||
}
|
||||
|
||||
public function getGlobalIdentifierKeyName(): string
|
||||
{
|
||||
return 'global_id';
|
||||
}
|
||||
|
||||
public function getCentralModelName(): string
|
||||
{
|
||||
return static::class;
|
||||
}
|
||||
|
||||
public function getSyncedAttributeNames(): array
|
||||
{
|
||||
return [
|
||||
'global_id',
|
||||
'name',
|
||||
'password',
|
||||
'email',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class TenantUserUsingPolymorphic extends Model implements Syncable
|
||||
{
|
||||
use ResourceSyncing;
|
||||
|
||||
protected $table = 'users';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
public function getGlobalIdentifierKey(): string|int
|
||||
{
|
||||
return $this->getAttribute($this->getGlobalIdentifierKeyName());
|
||||
}
|
||||
|
||||
public function getGlobalIdentifierKeyName(): string
|
||||
{
|
||||
return 'global_id';
|
||||
}
|
||||
|
||||
public function getCentralModelName(): string
|
||||
{
|
||||
return CentralUserUsingPolymorphic::class;
|
||||
}
|
||||
|
||||
public function getSyncedAttributeNames(): array
|
||||
{
|
||||
return [
|
||||
'global_id',
|
||||
'name',
|
||||
'password',
|
||||
'email',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class CentralCompanyUsingPolymorphic extends Model implements SyncMaster
|
||||
{
|
||||
use ResourceSyncing, CentralConnection;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
public $table = 'companies';
|
||||
|
||||
public function getTenantModelName(): string
|
||||
{
|
||||
return TenantCompanyUsingPolymorphic::class;
|
||||
}
|
||||
|
||||
public function getGlobalIdentifierKey(): string|int
|
||||
{
|
||||
return $this->getAttribute($this->getGlobalIdentifierKeyName());
|
||||
}
|
||||
|
||||
public function getGlobalIdentifierKeyName(): string
|
||||
{
|
||||
return 'global_id';
|
||||
}
|
||||
|
||||
public function getCentralModelName(): string
|
||||
{
|
||||
return static::class;
|
||||
}
|
||||
|
||||
public function getSyncedAttributeNames(): array
|
||||
{
|
||||
return [
|
||||
'global_id',
|
||||
'name',
|
||||
'email',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class TenantCompanyUsingPolymorphic extends Model implements Syncable
|
||||
{
|
||||
use ResourceSyncing;
|
||||
|
||||
protected $table = 'companies';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
public function getGlobalIdentifierKey(): string|int
|
||||
{
|
||||
return $this->getAttribute($this->getGlobalIdentifierKeyName());
|
||||
}
|
||||
|
||||
public function getGlobalIdentifierKeyName(): string
|
||||
{
|
||||
return 'global_id';
|
||||
}
|
||||
|
||||
public function getCentralModelName(): string
|
||||
{
|
||||
return CentralCompanyUsingPolymorphic::class;
|
||||
}
|
||||
|
||||
public function getSyncedAttributeNames(): array
|
||||
{
|
||||
return [
|
||||
'global_id',
|
||||
'name',
|
||||
'email',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
145
tests/SessionBootstrapperTest.php
Normal file
145
tests/SessionBootstrapperTest.php
Normal 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],
|
||||
]);
|
||||
|
|
@ -52,12 +52,13 @@ test('onfail logic can be customized', function () {
|
|||
->assertSee('foo');
|
||||
});
|
||||
|
||||
test('localhost is not a valid subdomain', function () {
|
||||
test('archte.ch is not a valid subdomain', function () {
|
||||
pest()->expectException(NotASubdomainException::class);
|
||||
|
||||
// This gets routed to the app, but with a request domain of 'archte.ch'
|
||||
$this
|
||||
->withoutExceptionHandling()
|
||||
->get('http://localhost/foo/abc/xyz');
|
||||
->get('http://archte.ch/foo/abc/xyz');
|
||||
});
|
||||
|
||||
test('ip address is not a valid subdomain', function () {
|
||||
|
|
@ -65,7 +66,7 @@ test('ip address is not a valid subdomain', function () {
|
|||
|
||||
$this
|
||||
->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 () {
|
||||
|
|
@ -81,7 +82,7 @@ test('oninvalidsubdomain logic can be customized', function () {
|
|||
|
||||
$this
|
||||
->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');
|
||||
});
|
||||
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
||||
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
|
||||
{
|
||||
use HasDomains;
|
||||
|
|
|
|||
|
|
@ -390,6 +390,81 @@ test('path used by sqlite manager can be customized', function () {
|
|||
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
|
||||
dataset('database_managers', [
|
||||
['mysql', MySQLDatabaseManager::class],
|
||||
|
|
|
|||
|
|
@ -83,6 +83,19 @@ test('tenant user can be impersonated on a tenant domain', function () {
|
|||
pest()->get('http://foo.localhost/dashboard')
|
||||
->assertSuccessful()
|
||||
->assertSee('You are logged in as Joe');
|
||||
|
||||
expect(UserImpersonation::isImpersonating())->toBeTrue();
|
||||
expect(session('tenancy_impersonating'))->toBeTrue();
|
||||
|
||||
// Leave impersonation
|
||||
UserImpersonation::leave();
|
||||
|
||||
expect(UserImpersonation::isImpersonating())->toBeFalse();
|
||||
expect(session('tenancy_impersonating'))->toBeNull();
|
||||
|
||||
// Assert can't access the tenant dashboard
|
||||
pest()->get('http://foo.localhost/dashboard')
|
||||
->assertRedirect('http://foo.localhost/login');
|
||||
});
|
||||
|
||||
test('tenant user can be impersonated on a tenant path', function () {
|
||||
|
|
@ -116,6 +129,19 @@ test('tenant user can be impersonated on a tenant path', function () {
|
|||
pest()->get('/acme/dashboard')
|
||||
->assertSuccessful()
|
||||
->assertSee('You are logged in as Joe');
|
||||
|
||||
expect(UserImpersonation::isImpersonating())->toBeTrue();
|
||||
expect(session('tenancy_impersonating'))->toBeTrue();
|
||||
|
||||
// Leave impersonation
|
||||
UserImpersonation::leave();
|
||||
|
||||
expect(UserImpersonation::isImpersonating())->toBeFalse();
|
||||
expect(session('tenancy_impersonating'))->toBeNull();
|
||||
|
||||
// Assert can't access the tenant dashboard
|
||||
pest()->get('/acme/dashboard')
|
||||
->assertRedirect('/login');
|
||||
});
|
||||
|
||||
test('tokens have a limited ttl', function () {
|
||||
|
|
|
|||
|
|
@ -4,16 +4,18 @@ declare(strict_types=1);
|
|||
|
||||
namespace Stancl\Tenancy\Tests;
|
||||
|
||||
use Dotenv\Dotenv;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use PDO;
|
||||
use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Facades\GlobalCache;
|
||||
use Dotenv\Dotenv;
|
||||
use Stancl\Tenancy\Facades\Tenancy;
|
||||
use Stancl\Tenancy\Features\CrossDomainRedirect;
|
||||
use Stancl\Tenancy\TenancyServiceProvider;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Stancl\Tenancy\Facades\GlobalCache;
|
||||
use Stancl\Tenancy\TenancyServiceProvider;
|
||||
use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\BroadcastTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\UrlTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\MailTenancyBootstrapper;
|
||||
|
||||
abstract class TestCase extends \Orchestra\Testbench\TestCase
|
||||
{
|
||||
|
|
@ -104,6 +106,9 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
|
|||
'--force' => true,
|
||||
],
|
||||
'tenancy.bootstrappers.redis' => RedisTenancyBootstrapper::class, // todo1 change this to []? two tests in TenantDatabaseManagerTest are failing with that
|
||||
'tenancy.bootstrappers.broadcast' => BroadcastTenancyBootstrapper::class, // todo1 change this to []? two tests in TenantDatabaseManagerTest are failing with that
|
||||
'tenancy.bootstrappers.mail' => MailTenancyBootstrapper::class,
|
||||
'tenancy.bootstrappers.url' => UrlTenancyBootstrapper::class,
|
||||
'queue.connections.central' => [
|
||||
'driver' => 'sync',
|
||||
'central' => true,
|
||||
|
|
@ -113,6 +118,9 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
|
|||
]);
|
||||
|
||||
$app->singleton(RedisTenancyBootstrapper::class); // todo (Samuel) use proper approach eg config for singleton registration
|
||||
$app->singleton(BroadcastTenancyBootstrapper::class);
|
||||
$app->singleton(MailTenancyBootstrapper::class);
|
||||
$app->singleton(UrlTenancyBootstrapper::class);
|
||||
}
|
||||
|
||||
protected function getPackageProviders($app)
|
||||
|
|
|
|||
|
|
@ -3,27 +3,24 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Stancl\Tenancy\Features\UniversalRoutes;
|
||||
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
|
||||
use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use Illuminate\Contracts\Http\Kernel;
|
||||
|
||||
afterEach(function () {
|
||||
InitializeTenancyByDomain::$onFail = null;
|
||||
});
|
||||
test('a route can work in both central and tenant context', function (array $routeMiddleware, string|null $globalMiddleware) {
|
||||
if ($globalMiddleware) {
|
||||
app(Kernel::class)->pushMiddleware($globalMiddleware);
|
||||
}
|
||||
|
||||
test('a route can work in both central and tenant context', function () {
|
||||
Route::middlewareGroup('universal', []);
|
||||
config(['tenancy.features' => [UniversalRoutes::class]]);
|
||||
|
||||
Route::get('/foo', function () {
|
||||
return tenancy()->initialized
|
||||
? 'Tenancy is initialized.'
|
||||
: 'Tenancy is not initialized.';
|
||||
})->middleware(['universal', InitializeTenancyByDomain::class]);
|
||||
|
||||
pest()->get('http://localhost/foo')
|
||||
->assertSuccessful()
|
||||
->assertSee('Tenancy is not initialized.');
|
||||
})->middleware($routeMiddleware);
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'id' => 'acme',
|
||||
|
|
@ -32,28 +29,33 @@ test('a route can work in both central and tenant context', function () {
|
|||
'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()
|
||||
->assertSee('Tenancy is initialized.');
|
||||
});
|
||||
})->with('identification types');
|
||||
|
||||
test('making one route universal doesnt make all routes universal', function () {
|
||||
Route::get('/bar', function () {
|
||||
return tenant('id');
|
||||
})->middleware(InitializeTenancyByDomain::class);
|
||||
test('making one route universal doesnt make all routes universal', function (array $routeMiddleware, string|null $globalMiddleware) {
|
||||
if ($globalMiddleware) {
|
||||
app(Kernel::class)->pushMiddleware($globalMiddleware);
|
||||
}
|
||||
|
||||
Route::middlewareGroup('universal', []);
|
||||
config(['tenancy.features' => [UniversalRoutes::class]]);
|
||||
|
||||
Route::get('/foo', function () {
|
||||
return tenancy()->initialized
|
||||
? 'Tenancy is initialized.'
|
||||
: 'Tenancy is not initialized.';
|
||||
})->middleware(['universal', InitializeTenancyByDomain::class]);
|
||||
Route::middleware($routeMiddleware)->group(function () {
|
||||
Route::get('/nonuniversal', function () {
|
||||
return tenant('id');
|
||||
});
|
||||
|
||||
pest()->get('http://localhost/foo')
|
||||
->assertSuccessful()
|
||||
->assertSee('Tenancy is not initialized.');
|
||||
Route::get('/universal', function () {
|
||||
return tenancy()->initialized
|
||||
? 'Tenancy is initialized.'
|
||||
: 'Tenancy is not initialized.';
|
||||
})->middleware('universal');
|
||||
});
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'id' => 'acme',
|
||||
|
|
@ -62,16 +64,57 @@ test('making one route universal doesnt make all routes universal', function ()
|
|||
'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()
|
||||
->assertSee('Tenancy is initialized.');
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
pest()->get('http://localhost/bar')
|
||||
->assertStatus(500);
|
||||
pest()->get('http://localhost/nonuniversal')
|
||||
->assertStatus(404);
|
||||
|
||||
pest()->get('http://acme.localhost/bar')
|
||||
pest()->get('http://acme.localhost/nonuniversal')
|
||||
->assertSuccessful()
|
||||
->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,
|
||||
]
|
||||
]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue