diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php new file mode 100644 index 000000000..9f74e835c --- /dev/null +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -0,0 +1,205 @@ + ['url' => 'webhookUrl']] + * - this maps $tenant->webhookUrl to slack.url (if $tenant->webhookUrl is not null, otherwise, the override is ignored) + * - Closure: ['slack' => fn (Tenant $tenant, array $channel) => array_merge($channel, ['url' => $tenant->slackUrl])] + * - this merges ['url' => $tenant->slackUrl] into the channel's config. + * + * So the channel overrides can be arrays and closures that return arrays. + */ + public static array $channelOverrides = []; + + public function __construct( + protected Config $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 (tenant) state, so we revert to the original config + // to e.g. avoid logging the failure in the tenant log (which would happen if + // the channel wasn't resolved before). + $this->revert(); + + 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: + * - the default channel (primarily because it can be 'stack') + * - all channels in the $storagePathChannels array + * - all channels that have custom overrides in the $channelOverrides property + */ + protected function getChannels(): array + { + /** + * Include the default channel in the list of channels to configure/re-resolve. + * + * Including the default channel is harmless (if it's not overridden or not in $storagePathChannels, + * it'll just be forgotten and re-resolved on the next use with the original config), and for the + * case where 'stack' is the default, this is necessary since the 'stack' channel will be resolved + * and saved in the log manager, and its stale config could accidentally be used instead of the stack member channels. + * + * For example, when you use 'stack' with the 'slack' channel, + * if only 'slack' is forgotten, 'stack' would still use the stale cached 'slack' driver, + * and if only 'stack' is forgotten, the 'slack' channel's config would remain unchanged (central). + */ + $defaultChannel = $this->config->get('logging.default'); + + return array_filter( + array_unique([ + $defaultChannel, + ...static::$storagePathChannels, + ...array_keys(static::$channelOverrides), + ]), + fn (string $channel): bool => $this->config->has("logging.channels.{$channel}") + ); + } + + /** + * Configure channels for the tenant context. + * + * Only the channels that are in the $storagePathChannels array + * or have custom overrides in the $channelOverrides property + * will be configured (overrides take precedence over storage path channels). + */ + 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 tenant-specific directory (default behavior). + // The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log", + // assuming FilesystemTenancyBootstrapper is used before this bootstrapper. + $originalChannelPath = $this->config->get("logging.channels.{$channel}.path"); + $centralStoragePath = Str::before(storage_path(), $this->config->get('tenancy.filesystem.suffix_base') . $tenant->getTenantKey()); + + // 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('custom/logs/path.log') (storage/custom/logs/path.log), + // the 'custom/logs/path.log' segment will be passed to storage_path() in the tenant context (storage/tenantfoo/custom/logs/path.log). + $this->config->set("logging.channels.{$channel}.path", storage_path(Str::after($originalChannelPath, $centralStoragePath))); + } + } + } + + protected function overrideChannelConfig(string $channel, array|Closure $override, Tenant $tenant): void + { + if (is_array($override)) { + // Map tenant attributes to channel config keys. + // If the tenant attribute is null, + // the override is ignored and the channel config key's value remains unchanged. + foreach ($override as $configKey => $tenantAttributeName) { + /** @var Tenant&Model $tenant */ + $tenantAttribute = Arr::get($tenant, $tenantAttributeName); + + 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 (tenant-specific) + * config on the next logging attempt. + */ + protected function forgetChannels(array $channels): void + { + foreach ($channels as $channel) { + $this->logManager->forgetChannel($channel); + } + } +} diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php new file mode 100644 index 000000000..6c73fdbb1 --- /dev/null +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -0,0 +1,462 @@ + [ + // FilesystemTenancyBootstrapper needed for LogTenancyBootstrapper to work with storage path channels BY DEFAULT + LogTenancyBootstrapper::class, + ], + ]); + + $cleanup(); + + Event::listen(TenancyInitialized::class, BootstrapTenancy::class); + Event::listen(TenancyEnded::class, RevertToCentralContext::class); +}); + +afterEach($cleanup); + +test('storage path channels get tenant-specific paths by default', function () { + // Note that for LogTenancyBootstrapper to change the paths correctly by default, + // the bootstrapper MUST run after FilesystemTenancyBootstrapper. + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + LogTenancyBootstrapper::class, + ], + ]); + + $centralStoragePath = storage_path(); + $tenant = Tenant::create(); + + // Storage path channels are 'single' and 'daily' by default. + // This can be customized via LogTenancyBootstrapper::$storagePathChannels. + foreach (LogTenancyBootstrapper::$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 the log stack get processed correctly', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + LogTenancyBootstrapper::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 + LogTenancyBootstrapper::$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 + LogTenancyBootstrapper::$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']); + + LogTenancyBootstrapper::$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']); + + LogTenancyBootstrapper::$channelOverrides = [ + 'single' => function (Tenant $tenant, array $channel) { + return array_merge($channel, ['path' => storage_path("logs/override-{$tenant->id}.log")]); + }, + ]; + + tenancy()->initialize($tenant); + + // Should use override, not the default 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, + LogTenancyBootstrapper::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, + LogTenancyBootstrapper::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']); + + LogTenancyBootstrapper::$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, + LogTenancyBootstrapper::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('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 + LogTenancyBootstrapper::$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, + LogTenancyBootstrapper::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. + LogTenancyBootstrapper::$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'); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index cbc6f57ec..ea4807350 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -23,10 +23,12 @@ 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\LogTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper; use Stancl\Tenancy\Bootstrappers\TenantConfigBootstrapper; +use function Stancl\Tenancy\Tests\pest; + abstract class TestCase extends \Orchestra\Testbench\TestCase { /** @@ -191,6 +193,7 @@ protected function getEnvironmentSetUp($app) $app->singleton(RootUrlBootstrapper::class); $app->singleton(UrlGeneratorBootstrapper::class); $app->singleton(FilesystemTenancyBootstrapper::class); + $app->singleton(LogTenancyBootstrapper::class); $app->singleton(TenantConfigBootstrapper::class); }