diff --git a/phpstan.neon b/phpstan.neon index bb97e3a0..acfe8358 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -15,9 +15,9 @@ parameters: ignoreErrors: - identifier: trait.unused - identifier: missingType.iterableValue - - - message: '#Spatie\\Invade\\Invader#' - identifier: method.notFound + #- + # message: '#Spatie\\Invade\\Invader#' + # identifier: method.notFound - message: '#Spatie\\Invade\\Invader#' identifier: property.notFound diff --git a/src/Bootstrappers/BroadcastingConfigBootstrapper.php b/src/Bootstrappers/BroadcastingConfigBootstrapper.php index 66fee704..bc4f75b9 100644 --- a/src/Bootstrappers/BroadcastingConfigBootstrapper.php +++ b/src/Bootstrappers/BroadcastingConfigBootstrapper.php @@ -4,14 +4,19 @@ declare(strict_types=1); 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,14 +26,14 @@ 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 = []; - public static string|null $broadcaster = null; - protected array $originalConfig = []; protected BroadcastManager|null $originalBroadcastManager = null; - protected Broadcaster|null $originalBroadcaster = null; + protected BroadcasterContract|null $originalBroadcaster = null; public static array $mapPresets = [ 'pusher' => [ @@ -52,36 +57,97 @@ class BroadcastingConfigBootstrapper implements TenancyBootstrapper public function __construct( protected Repository $config, protected Application $app - ) { - static::$broadcaster ??= $config->get('broadcasting.default'); - static::$credentialsMap = array_merge(static::$credentialsMap, static::$mapPresets[static::$broadcaster] ?? []); - } + ) {} public function bootstrap(Tenant $tenant): void { $this->originalBroadcastManager = $this->app->make(BroadcastManager::class); - $this->originalBroadcaster = $this->app->make(Broadcaster::class); + $this->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. + // The closure runs immediately (the extended singleton is already resolved), and it's also what makes + // channel auth work in tenant context -- the broadcaster resolved here gets cached as the tenant + // manager's default driver and receives the central broadcaster's auth properties (see copyAuthProperties()). + $this->app->extend(BroadcasterContract::class, function (BroadcasterContract $centralBroadcaster) { + $tenantBroadcaster = $this->app->make(BroadcastManager::class)->connection(); + + $this->copyAuthProperties($centralBroadcaster, $tenantBroadcaster); + + 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(); + } + + /** + * Copy the channel and auth properties (the registered channel auth closures, their + * options, and the authenticated user callback) from one broadcaster to another. A + * freshly resolved broadcaster has none of these set, so without the copying, channel + * auth and user auth would stop working (403) in tenant context. + * + * These properties are stored on the abstract Broadcaster class, not in the Broadcaster + * contract, and they're stored in protected properties. Because of that, we have + * to check that both broadcasters are instances of the abstract Broadcaster class and + * use invade() to access the protected properties (for the $channels property, there + * is a public accessor -- getChannels() -- but since invade is already used here, + * we access the property directly for consistency). + */ + protected function copyAuthProperties(BroadcasterContract $from, BroadcasterContract $to): void + { + if (! $from instanceof Broadcaster || ! $to instanceof Broadcaster) { + return; + } + + $fromState = invade($from); + $toState = invade($to); + + $toState->channels = $fromState->channels; + $toState->channelOptions = $fromState->channelOptions; + $toState->authenticatedUserCallback = $fromState->authenticatedUserCallback; } 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->instance(BroadcastManager::class, $this->originalBroadcastManager); + $this->app->instance(BroadcasterContract::class, $this->originalBroadcaster); + + // Clear the resolved Broadcast facade instance so that it gets re-resolved as the central BroadcastManager + Broadcast::clearResolvedInstance(); $this->unsetConfig(); } protected function setConfig(Tenant $tenant): void { - foreach (static::$credentialsMap as $configKey => $storageKey) { + $credentialsMap = array_merge( + static::$mapPresets[$this->config->get('broadcasting.default')] ?? [], + static::$credentialsMap, + ); + + foreach ($credentialsMap as $configKey => $storageKey) { $override = $tenant->$storageKey; if (array_key_exists($storageKey, $tenant->getAttributes())) { diff --git a/src/Overrides/TenancyBroadcastManager.php b/src/Overrides/TenancyBroadcastManager.php deleted file mode 100644 index 16e44a40..00000000 --- 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 785430f5..c6554702 100644 --- a/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php @@ -16,6 +16,9 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext; 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 @@ test('BroadcastChannelPrefixBootstrapper prefixes the channels events are broadc 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 5eb987db..ce898307 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -8,98 +8,345 @@ use Stancl\Tenancy\Events\TenancyInitialized; 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 () { +afterEach($cleanup = function () { + 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']; -}); +test('each context uses its own manager and broadcaster instances, and the bound broadcaster matches the manager default driver', function () { + config([ + 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], + 'broadcasting.default' => 'testing', + 'broadcasting.connections.testing.driver' => 'testing', + ]); -test('BroadcastingConfigBootstrapper binds TenancyBroadcastManager to BroadcastManager and reverts the binding when tenancy is ended', function() { - config(['tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class]]); + app(BroadcastManager::class)->extend('testing', fn ($app, $config) => new TestingBroadcaster('testing', $config)); - expect(app(BroadcastManager::class))->toBeInstanceOf(BroadcastManager::class); + $originalManager = app(BroadcastManager::class); + $originalBroadcaster = app(BroadcasterContract::class); + + // The bound broadcaster and the manager's default driver should be the same instance in every context + expect(app(BroadcasterContract::class))->toBe(app(BroadcastManager::class)->driver()); tenancy()->initialize(Tenant::create()); - expect(app(BroadcastManager::class))->toBeInstanceOf(TenancyBroadcastManager::class); + // BroadcastingConfigBootstrapper binds a fresh manager and a freshly resolved broadcaster + expect(app(BroadcastManager::class))->not()->toBe($originalManager); + 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(); - expect(app(BroadcastManager::class))->toBeInstanceOf(BroadcastManager::class); + // Ending tenancy reverts the bindings back to the original instances + expect(app(BroadcastManager::class))->toBe($originalManager); + expect(app(BroadcasterContract::class))->toBe($originalBroadcaster); + + // The bound broadcaster is still the same as the central manager's default driver + expect(app(BroadcasterContract::class))->toBe(app(BroadcastManager::class)->driver()); }); -test('BroadcastingConfigBootstrapper maps tenant broadcaster credentials to config as specified in the $credentialsMap property and reverts the config after ending tenancy', function() { +test('BroadcastingConfigBootstrapper maps tenant properties to broadcaster credentials correctly', function (string $driver) { config([ - 'broadcasting.connections.testing.driver' => 'testing', - 'broadcasting.connections.testing.message' => $defaultMessage = 'default', - 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], + 'broadcasting.default' => $driver, + "broadcasting.connections.{$driver}.key" => 'central_key', + 'tenancy.bootstrappers' => [ + BroadcastingConfigBootstrapper::class, + ], ]); - BroadcastingConfigBootstrapper::$credentialsMap = [ - 'broadcasting.connections.testing.message' => 'testing_broadcaster_message', - ]; + if ($driver === 'custom') { + config(['broadcasting.connections.custom.driver' => 'custom']); + } - $tenant = Tenant::create(['testing_broadcaster_message' => $tenantMessage = 'first testing']); - $tenant2 = Tenant::create(['testing_broadcaster_message' => $secondTenantMessage = 'second testing']); + BroadcastingConfigBootstrapper::$credentialsMap["broadcasting.connections.{$driver}.key"] = 'testing_key'; - tenancy()->initialize($tenant); + app(BroadcastManager::class)->extend($driver, fn ($app, $config) => new TestingBroadcaster('testing', $config)); - expect(array_key_exists('testing_broadcaster_message', tenant()->getAttributes()))->toBeTrue(); - expect(config('broadcasting.connections.testing.message'))->toBe($tenantMessage); + $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'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.testing.message'))->toBe($secondTenantMessage); + 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'); + + // Ending tenancy after a tenant overrode the credentials reverts the broadcasters back to the central credentials + tenancy()->end(); + + 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'); + + // 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.testing.message'))->toBe($defaultMessage); + 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); + + 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))->toContain('testing'); + expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toEqualCanonicalizing([...$originalDrivers, 'testing-tenant1']); + + tenancy()->initialize($tenant2); + + // 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))->toContain('testing'); + expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->not()->toContain('testing-tenant1'); + expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toEqualCanonicalizing($originalDrivers); + + tenancy()->end(); + + // 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 auth properties of 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', - ]; + $tenant = 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); + $resolveUser = fn () => app(BroadcasterContract::class)->resolveAuthenticatedUser(request()); - $registerTestingBroadcaster(); + Broadcast::channel($channel = 'testing-channel', $callback = fn () => true, $options = ['guards' => ['web']]); - expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($defaultMessage); + // resolveAuthenticatedUserUsing() sets the broadcaster's $authenticatedUserCallback, which should be + // copied to tenant broadcasters along with the channels. User authentication (/broadcasting/user-auth) + // only works when this callback is registered (403 otherwise), so an app that registers it in central + // context would get 403s from user auth in tenant context if the callback wasn't copied. + Broadcast::resolveAuthenticatedUserUsing(fn () => ['id' => 'central-user']); - $tenant = Tenant::create(['testing_broadcaster_message' => $tenantMessage = 'first testing']); - $tenant2 = Tenant::create(['testing_broadcaster_message' => $secondTenantMessage = 'second testing']); + expect($channel) + ->toBeIn($getCurrentChannelsThroughManager()) + ->toBeIn($getCurrentChannelsFromBoundBroadcaster()); + expect($resolveUser())->toBe(['id' => 'central-user']); tenancy()->initialize($tenant); - $registerTestingBroadcaster(); - expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($tenantMessage); + expect($channel) + ->toBeIn($getCurrentChannelsThroughManager()) + ->toBeIn($getCurrentChannelsFromBoundBroadcaster()); - tenancy()->initialize($tenant2); - $registerTestingBroadcaster(); - - expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($secondTenantMessage); + // The channel auth closure, the channel options, and the authenticated user resolver + // 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); + expect($resolveUser())->toBe(['id' => 'central-user']); tenancy()->end(); - $registerTestingBroadcaster(); - expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($defaultMessage); + expect($channel) + ->toBeIn($getCurrentChannelsThroughManager()) + ->toBeIn($getCurrentChannelsFromBoundBroadcaster()); + expect($resolveUser())->toBe(['id' => 'central-user']); }); + +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(); + + // 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); +}); + diff --git a/tests/BroadcastingTest.php b/tests/BroadcastingTest.php deleted file mode 100644 index c3509426..00000000 --- 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 1a225887..0b923f8f 100644 --- a/tests/Etc/TestingBroadcaster.php +++ b/tests/Etc/TestingBroadcaster.php @@ -6,7 +6,8 @@ use Illuminate\Broadcasting\Broadcasters\Broadcaster; class TestingBroadcaster extends Broadcaster { public function __construct( - public string $message = 'nothing' + public string $message = 'nothing', + public array $config = [], ) {} public function auth($request)