diff --git a/src/Bootstrappers/BroadcastingConfigBootstrapper.php b/src/Bootstrappers/BroadcastingConfigBootstrapper.php index 66fee7043..3ffd93376 100644 --- a/src/Bootstrappers/BroadcastingConfigBootstrapper.php +++ b/src/Bootstrappers/BroadcastingConfigBootstrapper.php @@ -4,14 +4,19 @@ namespace Stancl\Tenancy\Bootstrappers; +use Illuminate\Broadcasting\Broadcasters\Broadcaster; use Illuminate\Broadcasting\BroadcastManager; use Illuminate\Config\Repository; -use Illuminate\Contracts\Broadcasting\Broadcaster; +use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract; use Illuminate\Foundation\Application; +use Illuminate\Support\Facades\Broadcast; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; -use Stancl\Tenancy\Overrides\TenancyBroadcastManager; +/** + * Maps tenant credentials to the broadcasting config and rebinds BroadcastManager + * and Broadcaster so that broadcasters get resolved using the tenant credentials. + */ class BroadcastingConfigBootstrapper implements TenancyBootstrapper { /** @@ -21,6 +26,8 @@ class BroadcastingConfigBootstrapper implements TenancyBootstrapper * [ * 'config.key.name' => 'tenant_property', * ] + * + * $tenant->tenant_property will be mapped to config('config.key.name') when tenancy is initialized. */ public static array $credentialsMap = []; @@ -28,7 +35,7 @@ class BroadcastingConfigBootstrapper implements TenancyBootstrapper protected array $originalConfig = []; protected BroadcastManager|null $originalBroadcastManager = null; - protected Broadcaster|null $originalBroadcaster = null; + protected BroadcasterContract|null $originalBroadcaster = null; public static array $mapPresets = [ 'pusher' => [ @@ -54,27 +61,72 @@ public function __construct( protected Application $app ) { static::$broadcaster ??= $config->get('broadcasting.default'); - static::$credentialsMap = array_merge(static::$credentialsMap, static::$mapPresets[static::$broadcaster] ?? []); + static::$credentialsMap = array_merge(static::$mapPresets[static::$broadcaster] ?? [], static::$credentialsMap); } public function bootstrap(Tenant $tenant): void { $this->originalBroadcastManager = $this->app->make(BroadcastManager::class); - $this->originalBroadcaster = $this->app->make(Broadcaster::class); + $this->originalBroadcaster = $this->app->make(BroadcasterContract::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); + // Make BroadcastManager resolve to a fresh manager with no cached broadcasters, + // so that its broadcasters get resolved using the updated (tenant) broadcasting + // config and stay cached for the duration of the tenant's context. + $this->app->extend(BroadcastManager::class, function (BroadcastManager $centralManager) { + $tenantManager = new BroadcastManager($this->app); + + // Pass the custom driver creators registered in the central context to the new manager + // so that custom drivers work in tenant context without having to re-register the creators manually. + foreach (invade($centralManager)->customCreators as $driver => $creator) { + $tenantManager->extend($driver, $creator); + } + + return $tenantManager; + }); + + // Swap the currently bound Broadcaster singleton (resolved earlier with the central credentials) + // for the tenant BroadcastManager's default broadcaster, so that anything resolving the Broadcaster + // contract gets the same tenant broadcaster that the manager uses, instead of the stale central one. + $this->app->extend(BroadcasterContract::class, function (BroadcasterContract $centralBroadcaster) { + $tenantBroadcaster = $this->app->make(BroadcastManager::class)->connection(); + + // The newly resolved broadcaster doesn't have any channel auth closures registered, so the + // closures registered in central context (e.g. in routes/channels.php) have to be passed to it + // manually, otherwise, Broadcast::auth() would throw a 403 for those channels. + // Since Laravel only ever uses the default broadcaster's channel auth closures for broadcasting auth, + // we only have to pass the channel closures to the default broadcaster. + // + // The $channels property and the channel() method aren't part of the Broadcaster contract -- they come + // from the abstract Broadcaster class, so the closures can only be copied between broadcasters extending it + // (which all of Laravel's default broadcasters, e.g. PusherBroadcaster, do). + if ($centralBroadcaster instanceof Broadcaster && $tenantBroadcaster instanceof Broadcaster) { + // invade() because channels can't be retrieved through any of the broadcaster's public methods + $centralBroadcaster = invade($centralBroadcaster); + + foreach ($centralBroadcaster->channels as $channel => $callback) { + $tenantBroadcaster->channel($channel, $callback, $centralBroadcaster->retrieveChannelOptions($channel)); + } + } + + return $tenantBroadcaster; }); + + // Extending the binding doesn't update the Broadcast facade's cached instance, + // so clear it to make the facade re-resolve to the tenant BroadcastManager instead of the central + // one — e.g. in the Broadcast::auth() call in BroadcastController (/broadcasting/auth). + Broadcast::clearResolvedInstance(); } 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); + // Revert the bound BroadcastManager and Broadcaster singletons back to their original state + $this->app->singleton(BroadcastManager::class, fn () => $this->originalBroadcastManager); + $this->app->singleton(BroadcasterContract::class, fn () => $this->originalBroadcaster); + + // Clear the resolved Broadcast facade instance so that it gets re-resolved as the central BroadcastManager + Broadcast::clearResolvedInstance(); $this->unsetConfig(); } diff --git a/src/Overrides/TenancyBroadcastManager.php b/src/Overrides/TenancyBroadcastManager.php deleted file mode 100644 index 16e44a400..000000000 --- a/src/Overrides/TenancyBroadcastManager.php +++ /dev/null @@ -1,65 +0,0 @@ -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)); - } - } -} diff --git a/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php b/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php index 785430f52..c6554702a 100644 --- a/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php @@ -16,6 +16,9 @@ use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper; use function Stancl\Tenancy\Tests\pest; +use Illuminate\Broadcasting\Broadcasters\NullBroadcaster; +use Illuminate\Support\Facades\Broadcast; +use Illuminate\Support\Collection; beforeEach(function () { Event::listen(TenancyInitialized::class, BootstrapTenancy::class); @@ -137,3 +140,102 @@ protected function formatChannels(array $channels) expect(app(BroadcastManager::class)->driver())->toBe($broadcaster); expect(invade(app(BroadcastManager::class)->driver())->formatChannels($channelNames))->toEqual($channelNames); }); + +test('broadcasting channel helpers register channels correctly', function() { + config([ + 'broadcasting.default' => $driver = 'testing', + 'broadcasting.connections.testing.driver' => $driver, + ]); + + config(['tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class]]); + + Schema::create('users', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->string('email')->unique(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + $centralUser = User::create(['name' => 'central', 'email' => 'test@central.cz', 'password' => 'test']); + $tenant = Tenant::create(); + + migrateTenants(); + + tenancy()->initialize($tenant); + + // Same ID as $centralUser + $tenantUser = User::create(['name' => 'tenant', 'email' => 'test@tenant.cz', 'password' => 'test']); + + tenancy()->end(); + + /** @var BroadcastManager $broadcastManager */ + $broadcastManager = app(BroadcastManager::class); + + // Use a driver with no channels + $broadcastManager->extend($driver, fn () => new NullBroadcaster); + + $getChannels = fn (): Collection => $broadcastManager->driver($driver)->getChannels(); + + expect($getChannels())->toBeEmpty(); + + // Basic channel registration + Broadcast::channel($channelName = 'user.{userName}', $channelClosure = function ($user, $userName) { + return User::firstWhere('name', $userName)?->is($user) ?? false; + }); + + // Check if the channel is registered + $centralChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === $channelName); + expect($centralChannelClosure)->not()->toBeNull(); + + // Channel closures work as expected (running in central context) + expect($centralChannelClosure($centralUser, $centralUser->name))->toBeTrue(); + expect($centralChannelClosure($centralUser, $tenantUser->name))->toBeFalse(); + + // Register a tenant broadcasting channel (almost identical to the original channel, just able to accept the tenant key) + tenant_channel($channelName, $channelClosure); + + // Tenant channel registered – its name is correctly prefixed ("{tenant}.user.{userName}") + $tenantChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === "{tenant}.$channelName"); + expect($tenantChannelClosure)->toBe($centralChannelClosure); + + // The tenant channels are prefixed with '{tenant}.' + // They accept the tenant key, but their closures only run in tenant context when tenancy is initialized + // The regular channels don't accept the tenant key, but they also respect the current context + // The tenant key is used solely for the name prefixing – the closures can still run in the central context + tenant_channel($channelName, $tenantChannelClosure = function ($user, $tenant, $userName) { + return User::firstWhere('name', $userName)?->is($user) ?? false; + }); + + // Retrieve the stored closure to verify that re-registering the channel replaced it + // (asserting on $tenantChannelClosure wouldn't tell us what tenant_channel() actually stored) + $reregisteredTenantChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === "{tenant}.$channelName"); + + expect($reregisteredTenantChannelClosure) + ->toBe($tenantChannelClosure) + ->not()->toBe($centralChannelClosure); + + expect($reregisteredTenantChannelClosure($centralUser, $tenant->getTenantKey(), $centralUser->name))->toBeTrue(); + expect($reregisteredTenantChannelClosure($centralUser, $tenant->getTenantKey(), $tenantUser->name))->toBeFalse(); + + tenancy()->initialize($tenant); + + // The channel closure runs in the tenant context + // Only the tenant user is available + expect($tenantChannelClosure($centralUser, $tenant->getTenantKey(), $tenantUser->name))->toBeFalse(); + expect($tenantChannelClosure($tenantUser, $tenant->getTenantKey(), $tenantUser->name))->toBeTrue(); + + // Use a new channel instance to delete the previously registered channels before testing the global_channel helper + $broadcastManager->purge($driver); + $broadcastManager->extend($driver, fn () => new NullBroadcaster); + + expect($getChannels())->toBeEmpty(); + + // Global channel helper prefixes the channel name with 'global__' + global_channel($channelName, $channelClosure); + + // Channel prefixed with 'global__' found + $foundChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === 'global__' . $channelName); + expect($foundChannelClosure)->not()->toBeNull(); +}); diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index 5eb987db6..863d29360 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -8,98 +8,357 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Tests\Etc\TestingBroadcaster; use Stancl\Tenancy\Listeners\RevertToCentralContext; -use Stancl\Tenancy\Overrides\TenancyBroadcastManager; use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper; +use Illuminate\Support\Facades\Broadcast; +use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract; -beforeEach(function () { +$cleanup = function () { + BroadcastingConfigBootstrapper::$broadcaster = null; + BroadcastingConfigBootstrapper::$credentialsMap = []; + BroadcastingConfigBootstrapper::$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', + ], + 'reverb' => [ + 'broadcasting.connections.reverb.key' => 'reverb_key', + 'broadcasting.connections.reverb.secret' => 'reverb_secret', + 'broadcasting.connections.reverb.app_id' => 'reverb_app_id', + 'broadcasting.connections.reverb.options.cluster' => 'reverb_cluster', + ], + 'ably' => [ + 'broadcasting.connections.ably.key' => 'ably_key', + 'broadcasting.connections.ably.public' => 'ably_public', + ], + ]; +}; + +beforeEach(function () use ($cleanup) { Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class); - BroadcastingConfigBootstrapper::$credentialsMap = []; - TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably']; + $cleanup(); }); -afterEach(function () { - BroadcastingConfigBootstrapper::$credentialsMap = []; - TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably']; -}); +afterEach($cleanup); -test('BroadcastingConfigBootstrapper binds TenancyBroadcastManager to BroadcastManager and reverts the binding when tenancy is ended', function() { +test('BroadcastingConfigBootstrapper binds a fresh BroadcastManager and reverts the binding when tenancy is ended', function () { config(['tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class]]); - expect(app(BroadcastManager::class))->toBeInstanceOf(BroadcastManager::class); + $centralManager = app(BroadcastManager::class); tenancy()->initialize(Tenant::create()); - expect(app(BroadcastManager::class))->toBeInstanceOf(TenancyBroadcastManager::class); + expect(app(BroadcastManager::class)) + ->toBeInstanceOf(BroadcastManager::class) + ->not()->toBe($centralManager); tenancy()->end(); - expect(app(BroadcastManager::class))->toBeInstanceOf(BroadcastManager::class); + expect(app(BroadcastManager::class))->toBe($centralManager); }); -test('BroadcastingConfigBootstrapper maps tenant broadcaster credentials to config as specified in the $credentialsMap property and reverts the config after ending tenancy', function() { +test('ending tenancy reverts the bound broadcaster to the original instance', function () { config([ - 'broadcasting.connections.testing.driver' => 'testing', - 'broadcasting.connections.testing.message' => $defaultMessage = 'default', 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], + 'broadcasting.default' => 'testing', + 'broadcasting.connections.testing.driver' => 'testing', ]); - BroadcastingConfigBootstrapper::$credentialsMap = [ - 'broadcasting.connections.testing.message' => 'testing_broadcaster_message', - ]; + app(BroadcastManager::class)->extend('testing', fn ($app, $config) => new TestingBroadcaster('testing', $config)); + + $originalBroadcaster = app(BroadcasterContract::class); + + tenancy()->initialize(Tenant::create()); + + // BroadcastingConfigBootstrapper binds a freshly resolved broadcaster + expect(app(BroadcasterContract::class))->not()->toBe($originalBroadcaster); + + // The bound broadcaster is the same instance as the tenant BroadcastManager's default driver + expect(app(BroadcasterContract::class))->toBe(app(BroadcastManager::class)->driver()); + + tenancy()->end(); + + // Ending tenancy reverts the binding back to the original broadcaster instance + expect($originalBroadcaster)->toBe(app(BroadcasterContract::class)); +}); + +test('BroadcastingConfigBootstrapper maps tenant properties to broadcaster credentials correctly', function (string $driver) { + config([ + 'broadcasting.default' => $driver, + "broadcasting.connections.{$driver}.key" => 'central_key', + 'tenancy.bootstrappers' => [ + BroadcastingConfigBootstrapper::class, + ], + ]); + + if ($driver === 'custom') { + config(['broadcasting.connections.custom.driver' => 'custom']); + } + + BroadcastingConfigBootstrapper::$credentialsMap["broadcasting.connections.{$driver}.key"] = 'testing_key'; + + app(BroadcastManager::class)->extend($driver, fn ($app, $config) => new TestingBroadcaster('testing', $config)); + + $tenant1 = Tenant::create(['testing_key' => 'tenant1_key']); + $tenant2 = Tenant::create(['testing_key' => 'tenant2_key']); + + expect(config("broadcasting.connections.{$driver}.key"))->toBe('central_key'); + expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('central_key'); + expect(app(BroadcasterContract::class)->config['key'])->toBe('central_key'); + expect(Broadcast::driver()->config['key'])->toBe('central_key'); + + tenancy()->initialize($tenant1); - $tenant = Tenant::create(['testing_broadcaster_message' => $tenantMessage = 'first testing']); - $tenant2 = Tenant::create(['testing_broadcaster_message' => $secondTenantMessage = 'second testing']); + // Tenant's testing_key property is mapped to the current broadcasting connection key + expect(config("broadcasting.connections.{$driver}.key"))->toBe('tenant1_key'); + expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('tenant1_key'); + // Switching to tenant context makes the currently bound Broadcaster instance use the tenant's config + expect(app(BroadcasterContract::class)->config['key'])->toBe('tenant1_key'); + // The Broadcast facade (used in BroadcastController::authenticate) uses the broadcaster with tenant config + // instead of the stale broadcaster instance resolved before tenancy was initialized + expect(Broadcast::driver()->config['key'])->toBe('tenant1_key'); + + tenancy()->initialize($tenant2); + + expect(config("broadcasting.connections.{$driver}.key"))->toBe('tenant2_key'); + // Switching to another tenant context makes the current broadcaster use the new tenant's config + expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('tenant2_key'); + expect(app(BroadcasterContract::class)->config['key'])->toBe('tenant2_key'); + expect(Broadcast::driver()->config['key'])->toBe('tenant2_key'); + + $tenant2->update(['testing_key' => 'new_tenant2_key']); + + // Reinitialize tenancy to apply the tenant property update to config + tenancy()->end(); + tenancy()->initialize($tenant2); + + expect(config("broadcasting.connections.{$driver}.key"))->toBe('new_tenant2_key'); + expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('new_tenant2_key'); + expect(app(BroadcasterContract::class)->config['key'])->toBe('new_tenant2_key'); + expect(Broadcast::driver()->config['key'])->toBe('new_tenant2_key'); + + tenancy()->initialize($tenant1); + + // Direct config changes aren't picked up by the broadcasters -- they get resolved + // using the config mapped from tenant properties at initialization and stay cached + // until tenancy is reinitialized. + config(["broadcasting.connections.{$driver}.key" => 'new_tenant1_key']); + + expect(config("broadcasting.connections.{$driver}.key"))->toBe('new_tenant1_key'); + expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('tenant1_key'); + expect(app(BroadcasterContract::class)->config['key'])->toBe('tenant1_key'); + expect(Broadcast::driver()->config['key'])->toBe('tenant1_key'); + + // Initializing tenancy for a tenant without the mapped property keeps the central config value + tenancy()->initialize(Tenant::create()); + + expect(config("broadcasting.connections.{$driver}.key"))->toBe('central_key'); + expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('central_key'); + expect(app(BroadcasterContract::class)->config['key'])->toBe('central_key'); + + tenancy()->end(); + + expect(config("broadcasting.connections.{$driver}.key"))->toBe('central_key'); + // Ending tenancy reverts the broadcaster changes + expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('central_key'); + expect(app(BroadcasterContract::class)->config['key'])->toBe('central_key'); + expect(Broadcast::driver()->config['key'])->toBe('central_key'); +})->with([ + 'pusher', + 'ably', + 'reverb', + 'custom', +]); + +test('tenant broadcast manager receives the custom driver creators of the central broadcast manager', function () { + config([ + 'tenancy.bootstrappers' => [ + BroadcastingConfigBootstrapper::class, + ], + ]); + + $tenant = Tenant::create(); + $tenant2 = Tenant::create(); + + app(BroadcastManager::class)->extend('testing', fn($app, $config) => new TestingBroadcaster('testing', $config)); + + $originalDrivers = array_keys(invade(app(BroadcastManager::class))->customCreators); + + expect($originalDrivers)->toContain('testing'); tenancy()->initialize($tenant); - expect(array_key_exists('testing_broadcaster_message', tenant()->getAttributes()))->toBeTrue(); - expect(config('broadcasting.connections.testing.message'))->toBe($tenantMessage); + app(BroadcastManager::class)->extend( + 'testing-tenant1', + fn($app, $config) => new TestingBroadcaster('testing-tenant1', $config) + ); + + // Current BroadcastManager instance has the original custom creators plus the newly registered testing-tenant1 creator + expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toEqualCanonicalizing([...$originalDrivers, 'testing-tenant1']); tenancy()->initialize($tenant2); - expect(config('broadcasting.connections.testing.message'))->toBe($secondTenantMessage); + // Current BroadcastManager only has the original custom creators, + // the creator added in the previous tenant's context doesn't persist. + expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toEqualCanonicalizing($originalDrivers); tenancy()->end(); - expect(config('broadcasting.connections.testing.message'))->toBe($defaultMessage); + // Ending tenancy reverts the BroadcastManager binding back to the original state, + // the creator registered in the tenant context doesn't persist. + expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toEqualCanonicalizing($originalDrivers); }); -test('BroadcastingConfigBootstrapper makes the app use broadcasters with the correct credentials', function() { +test('tenant broadcasters receive the channel auth closures from the broadcaster bound in central context', function () { config([ + 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], 'broadcasting.default' => 'testing', 'broadcasting.connections.testing.driver' => 'testing', - 'broadcasting.connections.testing.message' => $defaultMessage = 'default', - 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], ]); - TenancyBroadcastManager::$tenantBroadcasters[] = 'testing'; - BroadcastingConfigBootstrapper::$credentialsMap = [ - 'broadcasting.connections.testing.message' => 'testing_broadcaster_message', - ]; + $tenant1 = Tenant::create(); + $tenant2 = Tenant::create(); - $registerTestingBroadcaster = fn() => app(BroadcastManager::class)->extend('testing', fn ($app, $config) => new TestingBroadcaster($config['message'])); + app(BroadcastManager::class)->extend('testing', fn () => new TestingBroadcaster('testing')); + $getCurrentChannelsFromBoundBroadcaster = fn () => array_keys(invade(app(BroadcasterContract::class))->channels); + $getCurrentChannelsThroughManager = fn () => array_keys(invade(app(BroadcastManager::class)->driver())->channels); - $registerTestingBroadcaster(); + Broadcast::channel($channel = 'testing-channel', $callback = fn () => true, $options = ['guards' => ['web']]); - expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($defaultMessage); + expect($channel) + ->toBeIn($getCurrentChannelsThroughManager()) + ->toBeIn($getCurrentChannelsFromBoundBroadcaster()); - $tenant = Tenant::create(['testing_broadcaster_message' => $tenantMessage = 'first testing']); - $tenant2 = Tenant::create(['testing_broadcaster_message' => $secondTenantMessage = 'second testing']); + tenancy()->initialize($tenant1); - tenancy()->initialize($tenant); - $registerTestingBroadcaster(); + expect($channel) + ->toBeIn($getCurrentChannelsThroughManager()) + ->toBeIn($getCurrentChannelsFromBoundBroadcaster()); - expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($tenantMessage); + // The channel auth closure and the channel options are copied to the tenant broadcaster as-is + expect(invade(app(BroadcasterContract::class))->channels[$channel])->toBe($callback); + expect(invade(app(BroadcasterContract::class))->retrieveChannelOptions($channel))->toBe($options); tenancy()->initialize($tenant2); - $registerTestingBroadcaster(); - expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($secondTenantMessage); + expect($channel) + ->toBeIn($getCurrentChannelsThroughManager()) + ->toBeIn($getCurrentChannelsFromBoundBroadcaster()); + + tenancy()->end(); + + expect($channel) + ->toBeIn($getCurrentChannelsThroughManager()) + ->toBeIn($getCurrentChannelsFromBoundBroadcaster()); +}); + +test('channels registered in tenant context persist within that context but do not leak into other contexts', function () { + config([ + 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], + 'broadcasting.default' => 'testing', + 'broadcasting.connections.testing.driver' => 'testing', + ]); + + app(BroadcastManager::class)->extend('testing', fn($app, $config) => new TestingBroadcaster('testing', $config)); + + Broadcast::channel('central-channel', fn () => true); + + tenancy()->initialize(Tenant::create()); + + Broadcast::channel('tenant-channel', fn () => true); + + // Retrieving the broadcaster again (e.g. on Broadcast::auth() during a /broadcasting/auth request) + // returns the cached broadcaster, so the channel registered in tenant context is still available + expect(array_keys(invade(Broadcast::driver())->channels)) + ->toContain('central-channel') + ->toContain('tenant-channel'); + + // The channel registered in the previous tenant's context doesn't leak to another tenant's broadcaster + tenancy()->initialize(Tenant::create()); + + expect(array_keys(invade(Broadcast::driver())->channels)) + ->toContain('central-channel') + ->not()->toContain('tenant-channel'); tenancy()->end(); - $registerTestingBroadcaster(); - expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($defaultMessage); + // The channel registered in tenant context doesn't leak to the central broadcaster + expect(array_keys(invade(Broadcast::driver())->channels)) + ->toContain('central-channel') + ->not()->toContain('tenant-channel'); +}); + +test('mappings specified in credentialsMap override default mapPresets', function ($driver) { + config([ + 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], + 'broadcasting.default' => $driver, + ]); + + // The preset mapping for the tested broadcaster (this is the default, we only set it here for clarity) + BroadcastingConfigBootstrapper::$mapPresets[$driver]["broadcasting.connections.{$driver}.key"] = "{$driver}_key"; + + // Custom mapping specified in credentialsMap should override the preset mapping for the tested broadcaster + BroadcastingConfigBootstrapper::$credentialsMap["broadcasting.connections.{$driver}.key"] = 'broadcasting_key'; + + app(BroadcastManager::class)->extend($driver, fn($app, $config) => new TestingBroadcaster('testing')); + + $tenant = Tenant::create([ + "{$driver}_key" => 'preset_value', + 'broadcasting_key' => 'custom_value', + ]); + + tenancy()->initialize($tenant); + + expect(config("broadcasting.connections.{$driver}.key"))->toBe('custom_value'); +})->with([ + 'pusher', + 'ably', + 'reverb', +]); + +test('initializing tenancy does not fail when the broadcaster does not extend the abstract Broadcaster class', function () { + config([ + 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], + 'broadcasting.default' => 'contract', + 'broadcasting.connections.contract.driver' => 'contract', + ]); + + $contractBroadcaster = new class implements BroadcasterContract { + public function auth($request) {} + public function validAuthenticationResponse($request, $result) {} + public function broadcast(array $channels, $event, array $payload = []) {} + }; + + app(BroadcastManager::class)->extend('contract', fn () => clone $contractBroadcaster); + + $centralBroadcaster = app(BroadcasterContract::class); + + // Channel auth closures only exist on broadcasters extending the abstract Broadcaster class, + // so the bootstrapper skips copying them instead of failing. + tenancy()->initialize(Tenant::create()); + + expect(app(BroadcasterContract::class)) + ->toBeInstanceOf(get_class($contractBroadcaster)) + ->not()->toBe($centralBroadcaster); +}); + +test('setting the broadcaster property overrides which map preset is used', function () { + config([ + 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], + 'broadcasting.default' => 'testing', + 'broadcasting.connections.testing.driver' => 'testing', + 'broadcasting.connections.pusher.key' => 'central_key', + ]); + + app(BroadcastManager::class)->extend('testing', fn () => new TestingBroadcaster('testing')); + + // Use the pusher preset even though the default connection isn't pusher + BroadcastingConfigBootstrapper::$broadcaster = 'pusher'; + + tenancy()->initialize(Tenant::create(['pusher_key' => 'tenant_key'])); + + expect(config('broadcasting.connections.pusher.key'))->toBe('tenant_key'); }); diff --git a/tests/BroadcastingTest.php b/tests/BroadcastingTest.php deleted file mode 100644 index c3509426b..000000000 --- a/tests/BroadcastingTest.php +++ /dev/null @@ -1,174 +0,0 @@ - [BroadcastingConfigBootstrapper::class]]); - 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(['tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class]]); - 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()); -}); - -test('broadcasting channel helpers register channels correctly', function() { - config([ - 'broadcasting.default' => $driver = 'testing', - 'broadcasting.connections.testing.driver' => $driver, - ]); - - config(['tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class]]); - - Schema::create('users', function (Blueprint $table) { - $table->increments('id'); - $table->string('name'); - $table->string('email')->unique(); - $table->string('password'); - $table->rememberToken(); - $table->timestamps(); - }); - - $centralUser = User::create(['name' => 'central', 'email' => 'test@central.cz', 'password' => 'test']); - $tenant = Tenant::create(); - - migrateTenants(); - - tenancy()->initialize($tenant); - - // Same ID as $centralUser - $tenantUser = User::create(['name' => 'tenant', 'email' => 'test@tenant.cz', 'password' => 'test']); - - tenancy()->end(); - - /** @var BroadcastManager $broadcastManager */ - $broadcastManager = app(BroadcastManager::class); - - // Use a driver with no channels - $broadcastManager->extend($driver, fn () => new NullBroadcaster); - - $getChannels = fn (): Collection => $broadcastManager->driver($driver)->getChannels(); - - expect($getChannels())->toBeEmpty(); - - // Basic channel registration - Broadcast::channel($channelName = 'user.{userName}', $channelClosure = function ($user, $userName) { - return User::firstWhere('name', $userName)?->is($user) ?? false; - }); - - // Check if the channel is registered - $centralChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === $channelName); - expect($centralChannelClosure)->not()->toBeNull(); - - // Channel closures work as expected (running in central context) - expect($centralChannelClosure($centralUser, $centralUser->name))->toBeTrue(); - expect($centralChannelClosure($centralUser, $tenantUser->name))->toBeFalse(); - - // Register a tenant broadcasting channel (almost identical to the original channel, just able to accept the tenant key) - tenant_channel($channelName, $channelClosure); - - // Tenant channel registered – its name is correctly prefixed ("{tenant}.user.{userId}") - $tenantChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === "{tenant}.$channelName"); - expect($tenantChannelClosure)->toBe($centralChannelClosure); - - // The tenant channels are prefixed with '{tenant}.' - // They accept the tenant key, but their closures only run in tenant context when tenancy is initialized - // The regular channels don't accept the tenant key, but they also respect the current context - // The tenant key is used solely for the name prefixing – the closures can still run in the central context - tenant_channel($channelName, $tenantChannelClosure = function ($user, $tenant, $userName) { - return User::firstWhere('name', $userName)?->is($user) ?? false; - }); - - expect($tenantChannelClosure)->not()->toBe($centralChannelClosure); - - expect($tenantChannelClosure($centralUser, $tenant->getTenantKey(), $centralUser->name))->toBeTrue(); - expect($tenantChannelClosure($centralUser, $tenant->getTenantKey(), $tenantUser->name))->toBeFalse(); - - tenancy()->initialize($tenant); - - // The channel closure runs in the central context - // Only the central user is available - expect($tenantChannelClosure($centralUser, $tenant->getTenantKey(), $tenantUser->name))->toBeFalse(); - expect($tenantChannelClosure($tenantUser, $tenant->getTenantKey(), $tenantUser->name))->toBeTrue(); - - // Use a new channel instance to delete the previously registered channels before testing the univeresal_channel helper - $broadcastManager->purge($driver); - $broadcastManager->extend($driver, fn () => new NullBroadcaster); - - expect($getChannels())->toBeEmpty(); - - // Global channel helper prefixes the channel name with 'global__' - global_channel($channelName, $channelClosure); - - // Channel prefixed with 'global__' found - $foundChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === 'global__' . $channelName); - expect($foundChannelClosure)->not()->toBeNull(); -}); diff --git a/tests/Etc/TestingBroadcaster.php b/tests/Etc/TestingBroadcaster.php index 1a2258875..0b923f8fb 100644 --- a/tests/Etc/TestingBroadcaster.php +++ b/tests/Etc/TestingBroadcaster.php @@ -6,7 +6,8 @@ class TestingBroadcaster extends Broadcaster { public function __construct( - public string $message = 'nothing' + public string $message = 'nothing', + public array $config = [], ) {} public function auth($request)