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

[MINOR BC] BroadcastingConfigBootstrapper rewrite, bugfixes (#1448)

> Minor breaking change: BroadcastingConfigBootstrapper::$broadcaster property removed, we now just use the default broadcaster for setting mappings based on a preset

### Broadcasting auth route problem (`Broadcast` facade always uses
central `BroadcastManager`)

> Note: Tested with Pusher and Reverb. Also, this is the primary problem
that this PR solves.

When using `BroadcastingConfigBootstrapper` for broadcasting on private
channels with multiple broadcasting apps (= each tenant has its own
Pusher/Reverb/... app and credentials), the auth requests sent to the
broadcasting server use the central broadcasting credentials. This is
because the `Broadcast` facade (used in
`BroadcastController::authenticate` like `Broadcast::auth($request)` to
authorize the channels **and then retrieve the auth key that's then sent
to the broadcasting server**) keeps using the `BroadcastManager`
instance resolved in the central context instead of the tenant
`BroadcastManager`. Calling `withBroadcasting()` in `bootstrap/app.php`
results in calling `Broadcast::routes()` in the central context, which
resolves and stores the central `BroadcastManager` (which is never
cleared, and is used in tenant context).

Clearing the facade's resolved instance
(`Illuminate\Contracts\Broadcasting\Factory`) in
`BroadcastingConfigBootstrapper::bootstrap()` forces the facade to
re-resolve `Factory` (= `BroadcastManager`) on the next use, so the next
`Broadcast::auth()` call uses the tenant `BroadcastManager` in the
tenant context (and clearing the resolved instance in `revert()` makes
the `Broadcast` facade use the central `BroadcastManager` again), and
the credentials from the current config will be used for authentication.

### Central `BroadcastManager` doesn't pass custom driver creators to
the tenant manager

When registering a custom driver creator
(`app(BroadcastManager::class)->extend('custom-driver', fn($app,
$config) => new CustomBroadcaster(...))`) in the central context (e.g.
in `BroadcastServiceProvider`/`AppServiceProvider`, or in our case, in
the tests), the creator won't be available in the tenant context.
Calling e.g. `app(BroadcastManager::class)->driver('custom-driver')` in
the tenant context (if the creator was registered only in the central
context) would throw `InvalidArgumentException` ("Driver [custom-driver]
is not supported.").

To fix that, register the original (central) `BroadcastManager`'s custom
creators in the new `BroadcastManager` in
`BroadcastingConfigBootstrapper::bootstrap()`.

> Note: This was always a problem, the tests just never caught it. The
original test where we used a custom driver creator actually registered
the same creator on each context switch (see the
`$registerTestingBroadcaster()` calls in the removed `BroadcastingTest`
file) -- if the creator was registered just once (which is what we do
now, after the fix), the test would fail. The test registered the driver
creator repeatedly which worked around custom creators not persisting
after switching between contexts.

### Central `Broadcaster` instance bound in tenant context

> Note: This section describes the problem with resolving/injecting the
`Broadcaster` contract directly (via DI or `app(Broadcaster::class)`).
The `extend()` call that fixes it also plays a second, bigger role --
see the "How `BroadcastingConfigBootstrapper::bootstrap()` works"
section below.

The problem was that resolving
`Illuminate\Contracts\Broadcasting\Broadcaster::class` in tenant context
returned the broadcaster instance from the central context (Laravel
binds the contract as a singleton, resolved from the default driver's
config as it was at resolution time -- before tenancy was initialized).
Fixed by making `Broadcaster::class` resolve to the current
`BroadcastManager`'s default broadcaster (= the tenant broadcaster in
tenant context) via `app->extend()`.

Since the manager caches its broadcasters (see "Broadcasters are
resolved once per tenancy initialization" below),
`app(Broadcaster::class)` and `Broadcast::driver()` return the same
instance in every context -- same as Laravel's default behavior.

### How `BroadcastingConfigBootstrapper::bootstrap()` works (the
`extend()` calls)

Both `extend()` calls run immediately during `bootstrap()`, not lazily
on some later resolution -- `extend()` executes the closure right away
when the extended singleton is already resolved, and both singletons are
resolved at the top of `bootstrap()` (where we save the central
instances for `revert()`).

Since the singletons are already resolved, the container also doesn't
store the closures as extenders -- they run once during `bootstrap()`
and that's it. Once `revert()` restores the original instances,
resolving either singleton again (even after a `forgetInstance()`) goes
purely through the original bindings. So when tenancy initializes:

1. `setConfig()` maps the tenant's properties to the broadcasting
config.
2. The `BroadcastManager` extend swaps the bound manager for a fresh one
with an empty driver cache and passes the central manager's custom
driver creators to it.
3. The `Broadcaster` contract extend calls `connection()` on the tenant
manager, which resolves the default broadcaster using the updated
(tenant) config and caches it as the manager's default driver. The
central broadcaster's auth properties -- the channel auth closures,
their options, and the authenticated user callback -- are then copied
onto this tenant broadcaster.
4. `Broadcast::clearResolvedInstance()` makes the facade re-resolve on
the next call, so it returns the tenant manager instead of the stale
central one.

When `/broadcasting/auth` gets hit later, `Broadcast::auth()` goes
through the tenant manager's `driver()`, which returns the broadcaster
cached in step 3 (built with the tenant credentials, using the copied
channel auth closures). This means the `Broadcaster` contract extend
isn't just for code that resolves or injects the contract directly --
it's also what makes channel auth work in tenant context. Without it,
`app(Broadcaster::class)` would keep returning the stale central
broadcaster, and the tenant broadcaster's `$channels` would stay empty,
so `Broadcast::auth()` would throw a 403 for every channel registered in
the central context. The same goes for user authentication --
`resolveAuthenticatedUser()` has no fallback, so without the copied
authenticated user callback (registered via
`Broadcast::resolveAuthenticatedUserUsing()`), `/broadcasting/user-auth`
would throw a 403 in tenant context.

`revert()` restores the original central manager and broadcaster
instances via `instance()` (they're saved at the top of `bootstrap()`
and nothing touches them while tenancy is initialized) and clears the
facade's resolved instance again.

### Broadcasters are resolved once per tenancy initialization,
`TenancyBroadcastManager` is removed

Originally, `TenancyBroadcastManager` re-resolved the broadcasters
listed in its `$tenantBroadcasters` static property on every retrieval.
Earlier, we considered that good since it meant direct config changes in
tenant context were picked up immediately. [As discussed in the
review](https://github.com/archtechx/tenancy/pull/1448#discussion_r3555941319),
there's probably no real use case for that, and the re-resolving
actually caused a subtle bug: channels registered via
`Broadcast::channel()` in tenant context got silently lost on the next
retrieval (e.g. during a `/broadcasting/auth` request), because each
re-resolution started over from a copy of the central channels -- making
the auth request fail with a 403 as if the channel was never registered.

Now, `TenancyBroadcastManager` is removed entirely.
`BroadcastingConfigBootstrapper` binds a fresh, base `BroadcastManager`
with no cached broadcasters, so the broadcasters get resolved using the
tenant's credentials on first use and stay cached (like in the parent
manager) for the duration of the tenant's context. The central
broadcaster's auth properties (the channel auth closures, their options,
and the authenticated user callback) are copied to the tenant
broadcaster directly in the `Broadcaster` contract's `extend()` closure
-- since Laravel only ever uses the *default* broadcaster's channel auth
closures for broadcasting auth (both `Broadcast::channel()` and
`Broadcast::auth()` go through the default broadcaster), the properties
only have to be copied to the default broadcaster.

The channel auth closures are always *only* on the default broadcaster,
even in plain Laravel. Every `Broadcast::channel()` call adds an entry
to the same `$channels` array on the default broadcaster -- a channel
with its own authorization rules is just one of those entries, so it
gets copied along with the rest. The only way to register a closure on a
non-default broadcaster is calling
`Broadcast::driver('foobar')->channel(...)` explicitly, and nothing in
Laravel's auth flow ever reads those (`/broadcasting/auth` always
authenticates using the default broadcaster).

This all means that:
- `TenancyBroadcastManager` is removed. Its `$tenantBroadcasters`
property's purpose was listing the broadcasters to re-resolve (and pass
the central channel auth closures to) -- custom drivers now work without
the need to configure anything, and the closure copying is a few lines
in the bootstrapper instead of a manager override.
- Channels registered via `Broadcast::channel()` in tenant context
persist for the duration of that context. They don't leak into other
tenants' contexts or into the central context.
- For broadcasters to use updated credentials, tenancy has to be
reinitialized. Direct broadcasting config changes made in tenant context
aren't picked up by the broadcasters, and tenant property changes are
only mapped to config in `bootstrap()`. An already-injected (= stale)
`Broadcaster` instance additionally needs to be obtained again after
reinitialization -- reinitializing swaps the bound instance, so a
previously injected instance keeps using the old credentials.

### NOTE: Already-connected clients stop receiving broadcasts after a
credential update

Updating a tenant's credentials doesn't disconnect already-connected
clients (tested with Reverb) -- they stay connected using the old key,
but they stop receiving broadcasts, since broadcasts sent after the
update are sent with the new credentials.

The frontend has to reconnect using the new key (e.g. by refreshing the
page). Meaning, clients can't be notified about the credential change
through websockets themselves -- a broadcast sent after the update
already uses the new credentials, so it won't reach clients connected
with the old key. So for things like notifying clients in response to
the credential changes, a different mechanism is needed.

### Credential map: overriding presets (BroadcastingConfigBootstrapper)

Previously, credential mappings from `$mapPresets` overrode mappings
defined in `$credentialsMap`. If someone used e.g. Pusher and wanted to
override some of that preset's mappings, e.g. use 'pusher_app_key'
instead of 'pusher_key' by specifying 'pusher_app_key' in
`$credentialsMap`, the preset's mapping ('pusher_key') would still be
used.

Fixed that by reversing the `array_merge()` order in
`BroadcastingConfigBootstrapper::__construct()`.

### **MINOR BC:** `BroadcastingConfigBootstrapper::$broadcaster`
property removed

`BroadcastingConfigBootstrapper::$broadcaster` determined which
`$mapPresets` preset to apply instead of just using
`broadcasting.default`. Its only effect was applying a preset for a
connection other than the default -- a connection nothing resolves, so
it did nothing useful. Set `$credentialsMap` directly if you need a
non-default mapping.

Removing it also let us drop the static mutation from the constructor,
which used to overwrite the user-configured `$credentialsMap`. The
preset merge now happens locally in `setConfig()`.

### Tests

Deleted the BroadcastingTest file, moved the tests to appropriate
bootstrapper test files.

Added tests
- for mapping tenant properties to broadcaster credentials (including
keeping the central config values when a tenant doesn't have a mapped
property, and reverting to the central credentials after a tenant with
credential overrides)
- for the rest of the changes mentioned above (including a regression
test for the tenant-context channel registration bug)
- for copying the channel options and the authenticated user callback
along with the channel auth closures
- for the bound `Broadcaster` and the manager's default broadcaster
being the same instance in central and tenant contexts
- for broadcasters that only implement the `Broadcaster` contract
(instead of extending the abstract class), and for configuring which map
preset is used via the `$broadcaster` property

Also improved the existing tests.

---------

Co-authored-by: Samuel Stancl <samuel@archte.ch>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
lukinovec 2026-07-23 09:26:04 +02:00 committed by GitHub
parent 76e5f96559
commit 553f57a8ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 484 additions and 307 deletions

View file

@ -15,9 +15,9 @@ parameters:
ignoreErrors: ignoreErrors:
- identifier: trait.unused - identifier: trait.unused
- identifier: missingType.iterableValue - identifier: missingType.iterableValue
- #-
message: '#Spatie\\Invade\\Invader#' # message: '#Spatie\\Invade\\Invader#'
identifier: method.notFound # identifier: method.notFound
- -
message: '#Spatie\\Invade\\Invader#' message: '#Spatie\\Invade\\Invader#'
identifier: property.notFound identifier: property.notFound

View file

@ -4,14 +4,19 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers; namespace Stancl\Tenancy\Bootstrappers;
use Illuminate\Broadcasting\Broadcasters\Broadcaster;
use Illuminate\Broadcasting\BroadcastManager; use Illuminate\Broadcasting\BroadcastManager;
use Illuminate\Config\Repository; use Illuminate\Config\Repository;
use Illuminate\Contracts\Broadcasting\Broadcaster; use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Broadcast;
use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant; 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 class BroadcastingConfigBootstrapper implements TenancyBootstrapper
{ {
/** /**
@ -21,14 +26,14 @@ class BroadcastingConfigBootstrapper implements TenancyBootstrapper
* [ * [
* 'config.key.name' => 'tenant_property', * '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 array $credentialsMap = [];
public static string|null $broadcaster = null;
protected array $originalConfig = []; protected array $originalConfig = [];
protected BroadcastManager|null $originalBroadcastManager = null; protected BroadcastManager|null $originalBroadcastManager = null;
protected Broadcaster|null $originalBroadcaster = null; protected BroadcasterContract|null $originalBroadcaster = null;
public static array $mapPresets = [ public static array $mapPresets = [
'pusher' => [ 'pusher' => [
@ -52,36 +57,97 @@ class BroadcastingConfigBootstrapper implements TenancyBootstrapper
public function __construct( public function __construct(
protected Repository $config, protected Repository $config,
protected Application $app 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 public function bootstrap(Tenant $tenant): void
{ {
$this->originalBroadcastManager = $this->app->make(BroadcastManager::class); $this->originalBroadcastManager = $this->app->make(BroadcastManager::class);
$this->originalBroadcaster = $this->app->make(Broadcaster::class); $this->originalBroadcaster = $this->app->make(BroadcasterContract::class);
$this->setConfig($tenant); $this->setConfig($tenant);
// Make BroadcastManager resolve to a custom BroadcastManager which makes the broadcasters use the tenant credentials // Make BroadcastManager resolve to a fresh manager with no cached broadcasters,
$this->app->extend(BroadcastManager::class, function (BroadcastManager $broadcastManager) { // so that its broadcasters get resolved using the updated (tenant) broadcasting
return new TenancyBroadcastManager($this->app); // 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 public function revert(): void
{ {
// Change the BroadcastManager and Broadcaster singletons back to what they were before initializing tenancy // Revert the bound BroadcastManager and Broadcaster singletons back to their original state
$this->app->singleton(BroadcastManager::class, fn (Application $app) => $this->originalBroadcastManager); $this->app->instance(BroadcastManager::class, $this->originalBroadcastManager);
$this->app->singleton(Broadcaster::class, fn (Application $app) => $this->originalBroadcaster); $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(); $this->unsetConfig();
} }
protected function setConfig(Tenant $tenant): void 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; $override = $tenant->$storageKey;
if (array_key_exists($storageKey, $tenant->getAttributes())) { if (array_key_exists($storageKey, $tenant->getAttributes())) {

View file

@ -1,65 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Overrides;
use Illuminate\Broadcasting\Broadcasters\Broadcaster;
use Illuminate\Broadcasting\BroadcastManager;
use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract;
use Illuminate\Contracts\Foundation\Application;
class TenancyBroadcastManager extends BroadcastManager
{
/**
* Names of broadcasters to always recreate using $this->resolve() (even when they're
* cached and available in the $broadcasters property).
*
* The reason for recreating the broadcasters is
* to make your app use the correct broadcaster credentials when tenancy is initialized.
*/
public static array $tenantBroadcasters = ['pusher', 'ably'];
/**
* Override the get method so that the broadcasters in $tenantBroadcasters
* always get freshly resolved even when they're cached and available in the $broadcasters property,
* and that the resolved broadcaster will override the BroadcasterContract::class singleton.
*
* If there's a cached broadcaster with the same name as $name,
* give its channels to the newly resolved bootstrapper.
*/
protected function get($name)
{
if (in_array($name, static::$tenantBroadcasters)) {
/** @var Broadcaster|null $originalBroadcaster */
$originalBroadcaster = $this->app->make(BroadcasterContract::class);
$newBroadcaster = $this->resolve($name);
// If there is a current broadcaster, give its channels to the newly resolved one
// Broadcasters only have to implement the Illuminate\Contracts\Broadcasting\Broadcaster contract
// Which doesn't require the channels property
// So passing the channels is only needed for Illuminate\Broadcasting\Broadcasters\Broadcaster instances
if ($originalBroadcaster instanceof Broadcaster && $newBroadcaster instanceof Broadcaster) {
$this->passChannelsFromOriginalBroadcaster($originalBroadcaster, $newBroadcaster);
}
$this->app->singleton(BroadcasterContract::class, fn (Application $app) => $newBroadcaster);
return $newBroadcaster;
}
return parent::get($name);
}
// Because, unlike the original broadcaster, the newly resolved broadcaster won't have the channels registered using routes/channels.php
// Using it for broadcasting won't work, unless we make it have the original broadcaster's channels
protected function passChannelsFromOriginalBroadcaster(Broadcaster $originalBroadcaster, Broadcaster $newBroadcaster): void
{
// invade() because channels can't be retrieved through any of the broadcaster's public methods
$originalBroadcaster = invade($originalBroadcaster);
foreach ($originalBroadcaster->channels as $channel => $callback) {
$newBroadcaster->channel($channel, $callback, $originalBroadcaster->retrieveChannelOptions($channel));
}
}
}

View file

@ -16,6 +16,9 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper; use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper;
use function Stancl\Tenancy\Tests\pest; use function Stancl\Tenancy\Tests\pest;
use Illuminate\Broadcasting\Broadcasters\NullBroadcaster;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\Collection;
beforeEach(function () { beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class); 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(app(BroadcastManager::class)->driver())->toBe($broadcaster);
expect(invade(app(BroadcastManager::class)->driver())->formatChannels($channelNames))->toEqual($channelNames); 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();
});

View file

@ -8,98 +8,345 @@ use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Tests\Etc\TestingBroadcaster; use Stancl\Tenancy\Tests\Etc\TestingBroadcaster;
use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Overrides\TenancyBroadcastManager;
use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper; 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(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class);
BroadcastingConfigBootstrapper::$credentialsMap = []; $cleanup();
TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably'];
}); });
afterEach(function () { test('each context uses its own manager and broadcaster instances, and the bound broadcaster matches the manager default driver', function () {
BroadcastingConfigBootstrapper::$credentialsMap = []; config([
TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably']; '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() { app(BroadcastManager::class)->extend('testing', fn ($app, $config) => new TestingBroadcaster('testing', $config));
config(['tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class]]);
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()); 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(); 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([ config([
'broadcasting.connections.testing.driver' => 'testing', 'broadcasting.default' => $driver,
'broadcasting.connections.testing.message' => $defaultMessage = 'default', "broadcasting.connections.{$driver}.key" => 'central_key',
'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], 'tenancy.bootstrappers' => [
BroadcastingConfigBootstrapper::class,
],
]); ]);
BroadcastingConfigBootstrapper::$credentialsMap = [ if ($driver === 'custom') {
'broadcasting.connections.testing.message' => 'testing_broadcaster_message', config(['broadcasting.connections.custom.driver' => 'custom']);
]; }
$tenant = Tenant::create(['testing_broadcaster_message' => $tenantMessage = 'first testing']); BroadcastingConfigBootstrapper::$credentialsMap["broadcasting.connections.{$driver}.key"] = 'testing_key';
$tenant2 = Tenant::create(['testing_broadcaster_message' => $secondTenantMessage = 'second testing']);
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(); $tenant1 = Tenant::create(['testing_key' => 'tenant1_key']);
expect(config('broadcasting.connections.testing.message'))->toBe($tenantMessage); $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); 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(); 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([ config([
'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class],
'broadcasting.default' => 'testing', 'broadcasting.default' => 'testing',
'broadcasting.connections.testing.driver' => 'testing', 'broadcasting.connections.testing.driver' => 'testing',
'broadcasting.connections.testing.message' => $defaultMessage = 'default',
'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class],
]); ]);
TenancyBroadcastManager::$tenantBroadcasters[] = 'testing'; $tenant = Tenant::create();
BroadcastingConfigBootstrapper::$credentialsMap = [
'broadcasting.connections.testing.message' => 'testing_broadcaster_message',
];
$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']); expect($channel)
$tenant2 = Tenant::create(['testing_broadcaster_message' => $secondTenantMessage = 'second testing']); ->toBeIn($getCurrentChannelsThroughManager())
->toBeIn($getCurrentChannelsFromBoundBroadcaster());
expect($resolveUser())->toBe(['id' => 'central-user']);
tenancy()->initialize($tenant); tenancy()->initialize($tenant);
$registerTestingBroadcaster();
expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($tenantMessage); expect($channel)
->toBeIn($getCurrentChannelsThroughManager())
->toBeIn($getCurrentChannelsFromBoundBroadcaster());
tenancy()->initialize($tenant2); // The channel auth closure, the channel options, and the authenticated user resolver
$registerTestingBroadcaster(); // are copied to the tenant broadcaster as-is
expect(invade(app(BroadcasterContract::class))->channels[$channel])->toBe($callback);
expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($secondTenantMessage); expect(invade(app(BroadcasterContract::class))->retrieveChannelOptions($channel))->toBe($options);
expect($resolveUser())->toBe(['id' => 'central-user']);
tenancy()->end(); 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);
});

View file

@ -1,174 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Collection;
use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
use Stancl\Tenancy\Events\TenancyEnded;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Broadcasting\BroadcastManager;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Tests\Etc\TestingBroadcaster;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Overrides\TenancyBroadcastManager;
use Illuminate\Broadcasting\Broadcasters\NullBroadcaster;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper;
use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract;
use function Stancl\Tenancy\Tests\withTenantDatabases;
beforeEach(function () {
withTenantDatabases();
TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably'];
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
});
afterEach(function () {
TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably'];
});
test('bound broadcaster instance is the same before initializing tenancy and after ending it', function() {
config(['tenancy.bootstrappers' => [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();
});

View file

@ -6,7 +6,8 @@ use Illuminate\Broadcasting\Broadcasters\Broadcaster;
class TestingBroadcaster extends Broadcaster { class TestingBroadcaster extends Broadcaster {
public function __construct( public function __construct(
public string $message = 'nothing' public string $message = 'nothing',
public array $config = [],
) {} ) {}
public function auth($request) public function auth($request)