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

Merge branch 'master' into broadcasting-fixes

This commit is contained in:
lukinovec 2026-07-10 11:10:01 +02:00 committed by GitHub
commit a19d3b532f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 799 additions and 24 deletions

View file

@ -185,6 +185,7 @@ return [
// Bootstrappers\RootUrlBootstrapper::class,
// Bootstrappers\UrlGeneratorBootstrapper::class,
// Bootstrappers\MailConfigBootstrapper::class,
// Bootstrappers\LogChannelBootstrapper::class,
// Bootstrappers\BroadcastingConfigBootstrapper::class,
// Bootstrappers\BroadcastChannelPrefixBootstrapper::class,

View file

@ -53,8 +53,8 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper
{
/** @var TenantWithDatabase $tenant */
if (data_get($tenant->database()->getTemplateConnection(), 'url')) {
// The package works with individual parts of the database connection config, so DATABASE_URL is not supported.
// When DATABASE_URL is set, this bootstrapper can silently fail i.e. keep using the template connection's database URL
// The package works with individual parts of the database connection config, so DB_URL is not supported.
// When DB_URL is set, this bootstrapper can silently fail i.e. keep using the template connection's database URL
// which takes precedence over individual segments of the connection config. This issue can be hard to debug as it can be
// production-specific. Therefore, we throw an exception (that effectively blocks all tenant pages) to prevent incorrect DB use.
throw new Exception('The template connection must NOT have URL defined. Specify the connection using individual parts instead of a database URL.');

View file

@ -255,4 +255,10 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
$sessionManager->getDrivers()['file']->setHandler($handler);
}
}
/** Get the central storage path from the bound singleton instance of this class. */
public static function getBoundCentralStoragePath(): string
{
return app(static::class)->originalStoragePath;
}
}

View file

@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers;
use Closure;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Log\LogManager;
use Illuminate\Support\Str;
use InvalidArgumentException;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
/**
* Use tenant-specific logging channels.
*
* Channels included in the $storagePathChannels property will be configured
* to write logs into the tenant's storage directory. The list includes
* Laravel's 'single' and 'daily' channels by default. To customize it,
* see the property's docblock.
*
* For the storage path channels to be scoped correctly:
* - this bootstrapper must run *after* FilesystemTenancyBootstrapper,
* since FilesystemTenancyBootstrapper adjusts storage_path() for the tenant
* - storage path suffixing has to be enabled (= config('tenancy.filesystem.suffix_storage_path')
* must be true), since the storage path suffix is what separates filesystem-based logs
*
* For logging channels that are not filesystem-based, see the $channelOverrides logic.
*
* @see Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper
*/
class LogChannelBootstrapper implements TenancyBootstrapper
{
protected array $defaultConfig = [];
protected array $configuredChannels = [];
/**
* Logging channels whose path is built using storage_path() (e.g. Laravel's 'single' and 'daily').
*
* Channels included here will be configured to use tenant-specific storage paths
* created using storage_path() in the tenant context. Overrides in the $channelOverrides
* property take precedence over $storagePathChannels when a channel is included in both.
*
* Requires FilesystemTenancyBootstrapper to run before this bootstrapper,
* and storage path suffixing to be enabled.
*
* @see Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper
*/
public static array $storagePathChannels = ['single', 'daily'];
/**
* Custom channel configuration overrides.
*
* Channels included here will be configured using the provided override.
* The overrides take precedence over the $storagePathChannels behavior
* when both approaches are used for the same channel.
*
* You can either map tenant attributes to channel config keys using an array,
* or provide a closure that returns the full channel config array.
*
* Examples:
* - Array mapping: ['slack' => ['url' => 'webhookUrl']]
* - this maps $tenant->webhookUrl to slack.url (if $tenant->webhookUrl is null, the override is ignored)
* - Closure: ['slack' => fn (Tenant $tenant, array $channel) => array_merge($channel, ['url' => $tenant->slackUrl])]
* - this manually merges ['url' => $tenant->slackUrl] into the channel's config
* - null is not ignored, the closure controls the override fully
*
* So the channel overrides can be arrays and closures that return arrays.
*/
public static array $channelOverrides = [];
public function __construct(
protected Repository $config,
protected LogManager $logManager,
) {}
public function bootstrap(Tenant $tenant): void
{
$this->defaultConfig = $this->config->get('logging.channels');
$this->configuredChannels = $this->getChannels();
try {
$this->configureChannels($this->configuredChannels, $tenant);
$this->forgetChannels($this->configuredChannels);
} catch (\Throwable $exception) {
// If an exception is thrown while updating the logging config, the logging config
// could be left in a corrupt state, so we revert to the original config to
// to avoid logging the exception in a tenant channel or a broken channel.
$this->revert();
// We re-throw the exception after having reverted the logging config to central.
throw $exception;
}
}
public function revert(): void
{
$this->config->set('logging.channels', $this->defaultConfig);
$this->forgetChannels($this->configuredChannels);
}
/**
* Channels to configure and forget from the log manager so they can be
* re-resolved with the new, tenant-specific config on the next use.
*
* Includes:
* - all channels in the $storagePathChannels array
* - all channels that have custom overrides in the $channelOverrides property
* - any 'stack' channel that includes one of the above as a member
*
* Stack channels are included because once a stack has been used, it keeps logging
* to wherever its members pointed to at that moment. So a stack used in the central
* context would keep writing to the central logs, even after tenancy is initialized
* and its member channels are configured for the tenant.
* Forgetting the stack forces it to be re-resolved with its members' updated (tenant)
* config.
*
* Importantly, stacks are only inspected one level deep - they are not traversed recursively.
*/
protected function getChannels(): array
{
$configuredChannels = array_unique([
...static::$storagePathChannels,
...array_keys(static::$channelOverrides),
]);
$stackChannels = [];
foreach ($this->config->get('logging.channels') as $channel => $config) {
// Include stack channels that have at least one configured channel as a member
if (($config['driver'] ?? null) === 'stack' && array_intersect($config['channels'] ?? [], $configuredChannels)) {
$stackChannels[] = $channel;
}
}
return array_filter(
array_unique([...$configuredChannels, ...$stackChannels]),
fn (string $channel): bool => $this->config->has("logging.channels.{$channel}")
);
}
/**
* Configure channels for the tenant context.
*
* This handles both $storagePathChannels and $channelOverrides.
*/
protected function configureChannels(array $channels, Tenant $tenant): void
{
foreach ($channels as $channel) {
if (isset(static::$channelOverrides[$channel])) {
$this->overrideChannelConfig($channel, static::$channelOverrides[$channel], $tenant);
} elseif (in_array($channel, static::$storagePathChannels)) {
// Set storage path channels to use a tenant-specific directory.
// The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log".
$originalChannelPath = $this->config->get("logging.channels.{$channel}.path");
$centralStoragePath = FilesystemTenancyBootstrapper::getBoundCentralStoragePath();
// The tenant log will inherit the segment that follows the storage path from the central channel path config.
// For example, if a channel's path is configured to storage_path('logs/foo.log') (storage/logs/foo.log),
// the 'logs/foo.log' segment will be passed to storage_path() in the tenant context (storage/tenant123/logs/foo.log).
$this->config->set("logging.channels.{$channel}.path", storage_path(Str::after($originalChannelPath, $centralStoragePath)));
}
}
}
/**
* Update channel configurations per $channelOverrides.
*
* For overrides set in array format, update individual keys of the channel.
* - This ignores cases where the value of the respective tenant attribute is null.
* For overrides set as closures, replace the entire channel with the returned config override.
* - This does not ignore cases where parts of the config may be null - the closure fully controls the override.
*/
protected function overrideChannelConfig(string $channel, array|Closure $override, Tenant $tenant): void
{
if (is_array($override)) {
// Map tenant attributes to channel config keys.
foreach ($override as $configKey => $tenantAttributeName) {
/** @var Tenant&Model $tenant */
$tenantAttribute = data_get($tenant, $tenantAttributeName);
// If the tenant attribute is null, the override is ignored
// and the channel config key's value remains unchanged.
if ($tenantAttribute !== null) {
$this->config->set("logging.channels.{$channel}.{$configKey}", $tenantAttribute);
}
}
} elseif ($override instanceof Closure) {
$channelConfigKey = "logging.channels.{$channel}";
$result = $override($tenant, $this->config->get($channelConfigKey));
if (! is_array($result)) {
throw new InvalidArgumentException("Channel override closure for '{$channel}' must return an array.");
}
$this->config->set($channelConfigKey, $result);
}
}
/**
* Forget all passed channels from the log manager so that they can be
* re-resolved with the updated config on the next logging attempt.
*/
protected function forgetChannels(array $channels): void
{
foreach ($channels as $channel) {
$this->logManager->forgetChannel($channel);
}
}
}

View file

@ -74,8 +74,8 @@ class DisallowSqliteAttach implements Feature
// @phpstan-ignore method.notFound
$pdo->setAuthorizer(static function (int $action): int {
return $action === 24 // SQLITE_ATTACH
? PDO\Sqlite::DENY
: PDO\Sqlite::OK;
? PDO\Sqlite::DENY // @phpstan-ignore classConstant.notFound
: PDO\Sqlite::OK; // @phpstan-ignore classConstant.notFound
});
}
}

View file

@ -22,13 +22,14 @@ trait TriggerSyncingEvents
public static function bootTriggerSyncingEvents(): void
{
static::saving(static function (self $pivot) {
// Try getting the central resource to see if it is available
// If it is not available, throw an exception to interrupt the saving process
// And prevent creating a pivot record without a central resource
// Try getting the central resource to see if it is available.
// If it is not, getCentralResourceAndTenant() throws (indirectly, via findCentralResource() -> getResourceClass()),
// interrupting the save, preventing the creation of a pivot record without a central resource.
$pivot->getCentralResourceAndTenant();
});
static::saved(static function (self $pivot) {
// Only attach when the pivot is created
static::created(static function (self $pivot) {
/**
* @var static&Pivot $pivot
* @var SyncMaster|null $centralResource
@ -55,6 +56,10 @@ trait TriggerSyncingEvents
});
}
/**
* @throws CentralResourceNotAvailableInPivotException Throws when the tenant is the pivot parent
* but the central resource class cannot be resolved (thrown indirectly via findCentralResource() -> getResourceClass())
*/
public function getCentralResourceAndTenant(): array
{
/** @var $this&Pivot $this */

View file

@ -30,7 +30,6 @@ if (! function_exists('tenant')) {
return app(Tenant::class);
}
// @phpstan-ignore-next-line nullsafe.neverNull
return app(Tenant::class)?->getAttribute($key);
}
}

View file

@ -12,7 +12,6 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\QueryException;
use Stancl\Tenancy\Database\TenantDatabaseManagers\MySQLDatabaseManager;
use Stancl\Tenancy\Database\TenantDatabaseManagers\SQLiteDatabaseManager;
use Stancl\Tenancy\Database\TenantDatabaseManagers\PostgreSQLDatabaseManager;
@ -116,7 +115,9 @@ test('harden prevents tenants from using the database of another tenant', functi
expect(fn () => tenancy()->initialize($tenant))->toThrow(RuntimeException::class);
// Connection should be reverted back to central
expect(DB::connection()->getName())->toBe('central');
$centralConnection = config('tenancy.database.central_connection');
expect(DB::connection()->getName())->toBe($centralConnection);
} else {
expect(fn() => tenancy()->initialize($tenant))->not()->toThrow(Throwable::class);
@ -128,25 +129,22 @@ test('harden prevents tenants from using the database of another tenant', functi
'hardening disabled' => false,
])->with('db_managers');
test('database tenancy bootstrapper throws an exception if DATABASE_URL is set', function (string|null $databaseUrl) {
config(['database.connections.central.url' => $databaseUrl]);
test('database tenancy bootstrapper throws an exception if DB_URL is set', function (string|null $databaseUrl) {
config(['tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class]]);
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
$tenant = Tenant::create();
config(['database.connections.central.url' => $databaseUrl]);
if ($databaseUrl) {
expect(fn() => Tenant::create())->toThrow(QueryException::class);
expect(fn() => tenancy()->initialize($tenant))
->toThrow(Exception::class, 'The template connection must NOT have URL defined.');
} else {
expect(function() {
$tenant1 = Tenant::create();
pest()->artisan('tenants:migrate');
tenancy()->initialize($tenant1);
})->not()->toThrow(Throwable::class);
expect(fn() => tenancy()->initialize($tenant))->not()->toThrow(Throwable::class);
}
})->with(['abc.us-east-1.rds.amazonaws.com', null]);

View file

@ -0,0 +1,522 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Event;
use Stancl\Tenancy\Tests\Etc\Tenant;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\LogChannelBootstrapper;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use Illuminate\Support\Facades\Log;
afterEach($cleanup = function () {
LogChannelBootstrapper::$channelOverrides = [];
LogChannelBootstrapper::$storagePathChannels = ['single', 'daily'];
$logFiles = array_merge(
glob(storage_path('logs/*.log')) ?: [],
glob(storage_path('logs/*/*.log')) ?: [],
glob(storage_path('tenant*/logs/*.log')) ?: [],
glob(storage_path('tenant*/logs/*/*.log')) ?: []
);
foreach ($logFiles as $path) {
@unlink($path);
}
});
beforeEach(function () use ($cleanup) {
config([
'tenancy.bootstrappers' => [
LogChannelBootstrapper::class,
],
]);
$cleanup();
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
});
test('storage path channels get tenant-specific paths by default', function () {
// Note that for LogChannelBootstrapper to change the paths correctly by default,
// the bootstrapper MUST run after FilesystemTenancyBootstrapper.
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
LogChannelBootstrapper::class,
],
]);
$centralStoragePath = storage_path();
$tenant = Tenant::create();
// Storage path channels are 'single' and 'daily' by default.
// This can be customized via LogChannelBootstrapper::$storagePathChannels.
foreach (LogChannelBootstrapper::$storagePathChannels as $channel) {
$originalPath = config("logging.channels.{$channel}.path");
tenancy()->initialize($tenant);
// Path should now point to the log in the tenant's storage directory
$tenantLogPath = "{$centralStoragePath}/tenant{$tenant->id}/logs/laravel.log";
expect(config("logging.channels.{$channel}.path"))
->not()->toBe($originalPath)
->toBe($tenantLogPath);
tenancy()->end();
// Path should be reverted
expect(config("logging.channels.{$channel}.path"))->toBe($originalPath);
}
});
test('all channels included in a stack get processed correctly', function () {
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
LogChannelBootstrapper::class,
],
'logging.channels.stack' => [
'driver' => 'stack',
'channels' => ['single', 'daily'],
],
]);
$centralStoragePath = storage_path();
$centralLogPath = $centralStoragePath . '/logs/laravel.log';
$originalSinglePath = config('logging.channels.single.path');
$originalDailyPath = config('logging.channels.daily.path');
// By default, both paths are the same in the config.
// Note that in actual usage, the daily log file name is parsed differently from the path in the config,
// e.g. if daily channel has 'path' => storage_path('logs/laravel.log') in config, the log will be
// located at storage_path('logs/laravel-2026-01-01.log'). But the paths *in the config* are the same.
expect($centralLogPath)
->toBe($centralStoragePath . '/logs/laravel.log')
->toBe($originalSinglePath)
->toBe($originalDailyPath);
$tenant = Tenant::create();
tenancy()->initialize($tenant);
// Both channels in the stack are updated correctly
expect("{$centralStoragePath}/tenant{$tenant->id}/logs/laravel.log")
->not()->toBe($originalSinglePath)
->not()->toBe($originalDailyPath)
->toBe(config('logging.channels.single.path'))
->toBe(config('logging.channels.daily.path'));
tenancy()->end();
expect(config('logging.channels.single.path'))->toBe($originalSinglePath);
expect(config('logging.channels.daily.path'))->toBe($originalDailyPath);
});
test('channel overrides work correctly with both arrays and closures', function () {
config([
'logging.channels.stack.channels' => ['slack', 'single'],
'logging.channels.slack' => [
'url' => $originalSlackUrl = 'default-webhook',
'username' => 'Default',
],
]);
$centralStoragePath = storage_path();
$originalSinglePath = config('logging.channels.single.path');
$tenant = Tenant::create(['webhookUrl' => 'tenant-webhook']);
// Channel override closures must return an array, otherwise an exception is thrown
LogChannelBootstrapper::$channelOverrides['slack'] = fn (Tenant $tenant, array $channel) => 'invalid override';
expect(fn() => tenancy()->initialize($tenant))->toThrow(InvalidArgumentException::class);
// Test both array mapping and closure-based overrides
LogChannelBootstrapper::$channelOverrides = [
'slack' => ['url' => 'webhookUrl'], // slack.url will be mapped to $tenant->webhookUrl
'single' => function (Tenant $tenant, array $channel) use ($centralStoragePath) {
return array_merge($channel, ['path' => $centralStoragePath . "/logs/override-{$tenant->id}.log"]);
},
];
// Reinitialize tenancy to apply the new overrides
tenancy()->reinitialize();
// Array mapping overrides work
expect(config('logging.channels.slack.url'))->toBe($tenant->webhookUrl);
expect(config('logging.channels.slack.username'))->toBe('Default'); // Default username, remains default unless overridden
// Closure overrides work
expect(config('logging.channels.single.path'))->toBe("{$centralStoragePath}/logs/override-{$tenant->id}.log");
tenancy()->end();
// After tenancy ends, the original config should be restored
expect(config('logging.channels.slack.url'))->toBe($originalSlackUrl);
expect(config('logging.channels.single.path'))->toBe($originalSinglePath);
expect(config('logging.channels.slack.username'))->toBe('Default'); // Unchanged
});
test('channel config keys remain unchanged if the specified tenant override attribute is null', function() {
config(['logging.channels.slack.username' => 'Default username']);
LogChannelBootstrapper::$channelOverrides = [
'slack' => ['username' => 'nonExistentAttribute'], // $tenant->nonExistentAttribute
];
tenancy()->initialize(Tenant::create());
// The username should remain unchanged since the tenant attribute is null
expect(config('logging.channels.slack.username'))->toBe('Default username');
});
test('channel overrides take precedence over the default storage path channel updating logic', function () {
$tenant = Tenant::create(['id' => 'tenant1']);
LogChannelBootstrapper::$storagePathChannels = ['single'];
LogChannelBootstrapper::$channelOverrides = [
'single' => function (Tenant $tenant, array $channel) {
return array_merge($channel, ['path' => storage_path("logs/override-{$tenant->id}.log")]);
},
];
tenancy()->initialize($tenant);
// Should use channel override, not the storage path updating behavior
expect(config('logging.channels.single.path'))->toEndWith('storage/logs/override-tenant1.log');
});
test('channels are forgotten and re-resolved during bootstrap and revert', function () {
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
LogChannelBootstrapper::class,
],
]);
$logManager = app('log');
$originalChannel = $logManager->channel('single');
$originalSinglePath = config('logging.channels.single.path');
$tenant = Tenant::create();
tenancy()->initialize($tenant);
// After bootstrap, the channel should be a new instance with the updated config
$tenantChannel = $logManager->channel('single');
$tenantSingleChannelPath = $tenantChannel->getLogger()->getHandlers()[0]->getUrl();
expect($tenantChannel)->not()->toBe($originalChannel);
expect($tenantSingleChannelPath)
->not()->toBe($originalSinglePath)
->toEndWith("storage/tenant{$tenant->id}/logs/laravel.log");
tenancy()->end();
// After revert, the channel should get re-resolved with the original config
$currentChannel = $logManager->channel('single');
$currentChannelPath = $currentChannel->getLogger()->getHandlers()[0]->getUrl();
expect($currentChannel)->not()->toBe($tenantChannel);
expect($currentChannelPath)->toBe($originalSinglePath);
});
// Test real usage
test('logs are written to tenant-specific files and do not leak between contexts', function () {
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
LogChannelBootstrapper::class,
],
]);
$centralLogPath = storage_path('logs/laravel.log');
Log::channel('single')->info('central');
expect(file_get_contents($centralLogPath))->toContain('central');
[$tenant1, $tenant2] = [Tenant::create(['id' => 'tenant1']), Tenant::create(['id' => 'tenant2'])];
tenancy()->runForMultiple([$tenant1, $tenant2], function (Tenant $tenant) use ($centralLogPath) {
Log::channel('single')->info($tenant->id);
$tenantLogPath = storage_path('logs/laravel.log');
// The log gets saved to the tenant's storage directory (default behavior)
expect($tenantLogPath)
->not()->toBe($centralLogPath)
->toEndWith("storage/tenant{$tenant->id}/logs/laravel.log");
expect(file_get_contents($tenantLogPath))
->toContain($tenant->id)
->not()->toContain('central');
});
// Tenant log messages didn't leak into central log
expect(file_get_contents($centralLogPath))
->toContain('central')
->not()->toContain('tenant1')
->not()->toContain('tenant2');
// Tenant log messages didn't leak to logs of other tenants
tenancy()->initialize($tenant1);
expect(file_get_contents(storage_path('logs/laravel.log')))
->toContain('tenant1')
->not()->toContain('central')
->not()->toContain('tenant2');
tenancy()->initialize($tenant2);
expect(file_get_contents(storage_path('logs/laravel.log')))
->toContain('tenant2')
->not()->toContain('central')
->not()->toContain('tenant1');
// Overriding the channels also works
// Channel overrides also override the default behavior for the storage path-based channels
$tenant = Tenant::create(['id' => 'override-tenant']);
LogChannelBootstrapper::$channelOverrides = [
'single' => function (Tenant $tenant, array $channel) {
// The tenant log path will be set to storage/tenantoverride-tenant/logs/custom-override-tenant.log
return array_merge($channel, ['path' => storage_path("logs/custom-{$tenant->id}.log")]);
},
];
// Tenant context log (should use custom path due to override)
tenancy()->initialize($tenant);
Log::channel('single')->info('tenant-override');
expect(file_get_contents(storage_path('logs/custom-override-tenant.log')))->toContain('tenant-override');
});
test('stack logs are written to all configured channels with tenant-specific paths', function () {
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
LogChannelBootstrapper::class,
],
'logging.channels.stack' => [
'driver' => 'stack',
'channels' => ['single', 'daily'],
],
]);
$tenant = Tenant::create(['id' => 'stack-tenant']);
$today = now()->format('Y-m-d');
// Central context stack log
Log::channel('stack')->info('central');
$centralSingleLogPath = storage_path('logs/laravel.log');
// The single and daily channels have the same path in the config, but the daily driver parses the file name so that the date is included in the file name
$centralDailyLogPath = storage_path("logs/laravel-{$today}.log");
expect(file_get_contents($centralSingleLogPath))->toContain('central');
expect(file_get_contents($centralDailyLogPath))->toContain('central');
// Tenant context stack log
tenancy()->initialize($tenant);
Log::channel('stack')->info('tenant');
$tenantSingleLogPath = storage_path('logs/laravel.log');
$tenantDailyLogPath = storage_path("logs/laravel-{$today}.log");
expect(file_get_contents($tenantSingleLogPath))->toContain('tenant');
expect(file_get_contents($tenantDailyLogPath))->toContain('tenant');
// Verify tenant logs don't contain central messages
expect(file_get_contents($tenantSingleLogPath))->not()->toContain('central');
expect(file_get_contents($tenantDailyLogPath))->not()->toContain('central');
tenancy()->end();
// Verify central logs still only contain the central messages
expect(file_get_contents($centralSingleLogPath))
->toContain('central')
->not()->toContain('tenant');
expect(file_get_contents($centralDailyLogPath))
->toContain('central')
->not()->toContain('tenant');
});
test('stack channels that include any configured channel are re-resolved', function () {
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
LogChannelBootstrapper::class,
],
'logging.channels.custom_stack' => [
'driver' => 'stack',
'channels' => ['single'],
],
]);
$tenant = Tenant::create(['id' => 'stack-tenant']);
$centralLogPath = storage_path('logs/laravel.log');
$logManager = app('log');
// Resolve the stack channel in the central context first
// (this caches the stack with its members still pointing at the central logs).
$originalStackChannel = $logManager->channel('custom_stack');
$originalStackChannel->info('central log message');
expect(file_get_contents($centralLogPath))->toContain('central log message');
tenancy()->initialize($tenant);
// The stack channel should have been re-resolved with the
// updated (tenant) config for its member channels
$tenantStackChannel = $logManager->channel('custom_stack');
expect($tenantStackChannel)->not()->toBe($originalStackChannel);
$tenantStackChannel->info('tenant log message');
// so 'tenant log message' should be logged to the tenant log,
// not the central log.
expect(file_get_contents($centralLogPath))
->toContain('central log message')
->not()->toContain('tenant log message');
$tenantLogPath = storage_path('logs/laravel.log');
expect(file_exists($tenantLogPath))->toBeTrue();
expect(file_get_contents($tenantLogPath))
->toContain('tenant log message');
tenancy()->end();
// After revert, the stack channel should get re-resolved again with the original config
$currentStackChannel = $logManager->channel('custom_stack');
expect($currentStackChannel)->not()->toBe($tenantStackChannel);
$currentStackChannel->info('central after revert');
expect(file_get_contents($centralLogPath))
->toContain('central log message')
->toContain('central after revert')
->not()->toContain('tenant log message');
expect(file_get_contents($tenantLogPath))
->toContain('tenant log message')
->not()->toContain('central after revert');
});
test('slack channel uses correct webhook urls', function () {
config([
'logging.channels.slack.url' => 'central-webhook',
'logging.channels.slack.level' => 'debug', // Set level to debug to keep the tests simple, since the default level here is 'critical'
]);
$assertWebhook = function (string $expectedWebhook, string $message): void {
$thrown = false;
// Because the Slack channel uses cURL to send messages, we cannot use Http::fake() here.
// Instead, we catch the exception and check the error message which contains the actual webhook URL
// (logging always throws "Curl error (code 6): Could not resolve host: {webhook_url}").
try {
Log::channel('slack')->info($message);
} catch (Exception $e) {
$thrown = true;
expect($e->getMessage())->toContain($expectedWebhook);
}
expect($thrown)->toBeTrue();
};
$tenant1 = Tenant::create(['id' => 'tenant1', 'logging' => ['slackUrl' => 'tenant1-webhook']]);
$tenant2 = Tenant::create(['id' => 'tenant2', 'logging' => ['slackUrl' => 'tenant2-webhook']]);
// Attribute mapping using nested attributes (dot notation) works
LogChannelBootstrapper::$channelOverrides = [
'slack' => ['url' => 'logging.slackUrl'],
];
// Test central context - should attempt to use central webhook
$assertWebhook('central-webhook', 'central');
// Slack channel should attempt to use the tenant-specific webhooks
tenancy()->runForMultiple([$tenant1, $tenant2], function (Tenant $tenant) use ($assertWebhook) {
$assertWebhook($tenant->logging['slackUrl'], $tenant->id);
});
// Central context, central webhook should be used again
$assertWebhook('central-webhook', 'central');
});
test('tenant logs inherit the path from the central log path config', function () {
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
LogChannelBootstrapper::class,
],
'logging.channels.stack' => [
'driver' => 'stack',
'channels' => ['single', 'daily'],
],
'logging.channels.single.path' => storage_path('logs/single/custom-name.log'),
'logging.channels.daily.path' => storage_path('logs/daily/custom-name.log'),
]);
$tenant = Tenant::create();
$today = now()->format('Y-m-d');
Log::channel('stack')->info('central');
expect(file_get_contents(storage_path('logs/single/custom-name.log')))->toContain('central');
expect(file_get_contents(storage_path("logs/daily/custom-name-{$today}.log")))->toContain('central');
tenancy()->initialize($tenant);
// Tenant log is located at storage/tenantX/logs/custom-name.log
Log::channel('stack')->info($tenant->id);
// The filename from the central config is preserved in tenant context
expect(config('logging.channels.single.path'))->toEndWith('logs/single/custom-name.log');
expect(config('logging.channels.daily.path'))->toEndWith('logs/daily/custom-name.log');
expect(file_get_contents(storage_path('logs/single/custom-name.log')))
->toContain($tenant->id)
->not()->toContain('central');
expect(file_get_contents(storage_path("logs/daily/custom-name-{$today}.log")))
->toContain($tenant->id)
->not()->toContain('central');
});
test('logging config is reverted to the original state if configuration fails', function() {
config([
'logging.channels.slack.url' => $originalSlackUrl = 'default-webhook',
'logging.channels.single.path' => $originalSinglePath = storage_path('logs/default-single-path.log'),
]);
$tenant = Tenant::create(['loggingPath' => storage_path('logs/tenant-single-path.log')]);
// Valid override first, the config will be updated properly,
// then an invalid override that will cause the configuration to fail and throw an exception.
LogChannelBootstrapper::$channelOverrides = [
'single' => ['path' => 'loggingPath'], // Valid override
'slack' => fn () => 'invalid override',
];
expect(fn() => tenancy()->initialize($tenant))->toThrow(InvalidArgumentException::class);
// Single channel config reverted to original state after the exception was thrown
expect(config('logging.channels.single.path'))->toBe($originalSinglePath);
// Exception thrown before slack config got changed
expect(config('logging.channels.slack.url'))->toBe($originalSlackUrl);
// The single channel uses the original path for logging
Log::channel('single')->info('bootstrap failed');
expect(file_exists($originalSinglePath))->toBeTrue();
expect(file_get_contents($originalSinglePath))->toContain('bootstrap failed');
});

View file

@ -274,6 +274,32 @@ test('attaching central resources to tenants or vice versa creates synced tenant
});
});
test('updating pivot column does not re-create the synced tenant resource', function () {
// Add an extra pivot column so we can update it
Schema::table('tenant_users', fn (Blueprint $table) => $table->string('note')->nullable());
$centralUser = CentralUser::create([
'global_id' => 'acme',
'name' => 'John Doe',
'email' => 'john@localhost',
'password' => 'secret',
'role' => 'commenter',
]);
$tenant = Tenant::create();
migrateUsersTableForTenants();
// Attaching creates the tenant resource
$centralUser->tenants()->attach($tenant);
$tenant->run(fn () => expect(TenantUser::count())->toBe(1));
// Updating a pivot column does not re-attach and create the resource again
// (which would throw a duplicate entry error -- regression test for #1467)
$centralUser->tenants()->updateExistingPivot($tenant->getTenantKey(), ['note' => 'foo']);
$tenant->run(fn () => expect(TenantUser::count())->toBe(1));
});
test('detaching central users from tenants or vice versa force deletes the synced tenant resource', function (bool $attachUserToTenant) {
$centralUser = CentralUser::create([
'global_id' => 'acme',

View file

@ -23,10 +23,12 @@ use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper;
use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper;
use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use function Stancl\Tenancy\Tests\pest;
use Stancl\Tenancy\Bootstrappers\LogChannelBootstrapper;
use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper;
use Stancl\Tenancy\Bootstrappers\TenantConfigBootstrapper;
use function Stancl\Tenancy\Tests\pest;
abstract class TestCase extends \Orchestra\Testbench\TestCase
{
/**
@ -131,7 +133,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
'cache.stores.apc' => ['driver' => 'apc'],
'database.connections.central' => [
'driver' => 'mysql',
'url' => env('DATABASE_URL'),
'url' => env('DB_URL'),
'host' => 'mysql',
'port' => env('DB_PORT', '3306'),
'database' => 'main',
@ -191,6 +193,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
$app->singleton(RootUrlBootstrapper::class);
$app->singleton(UrlGeneratorBootstrapper::class);
$app->singleton(FilesystemTenancyBootstrapper::class);
$app->singleton(LogChannelBootstrapper::class);
$app->singleton(TenantConfigBootstrapper::class);
}