From fb329cd29e301569a21278d119e26faa21e2d971 Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Mon, 14 Sep 2026 12:39:38 -0700 Subject: [PATCH 1/5] Update workflow reference to enable-octane-cicd --- .github/workflows/deploy-pm4.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-pm4.yml b/.github/workflows/deploy-pm4.yml index 4b05f4736b..176a237412 100644 --- a/.github/workflows/deploy-pm4.yml +++ b/.github/workflows/deploy-pm4.yml @@ -15,5 +15,5 @@ concurrency: jobs: run: name: Run PM4-workflow - uses: processmaker/.github/.github/workflows/deploy-pm4.yml@main + uses: processmaker/.github/.github/workflows/deploy-pm4.yml@enable-octane-cicd secrets: inherit From 0c649e9a285e76f0e14935ac4fafb784bf949420 Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Tue, 15 Sep 2026 15:25:35 -0700 Subject: [PATCH 2/5] Skip warming AnonymouUser in multitenancy --- config/octane.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/config/octane.php b/config/octane.php index 13cddfe169..45ee55519d 100644 --- a/config/octane.php +++ b/config/octane.php @@ -22,6 +22,9 @@ use Laravel\Octane\Listeners\ReportException; use Laravel\Octane\Listeners\StopWorkerIfNecessary; use Laravel\Octane\Octane; +use ProcessMaker\Models\AnonymousUser; + +$multitenancyEnabled = filter_var(env('MULTITENANCY', false), FILTER_VALIDATE_BOOL); return [ @@ -132,7 +135,11 @@ 'warm' => [ ...Octane::defaultServicesToWarm(), - ProcessMaker\Models\AnonymousUser::class, + // AnonymousUser is tenant-scoped; warming it at worker boot queries the + // default connection before any tenant is resolved (breaks multitenancy). + ...($multitenancyEnabled ? [] : [ + AnonymousUser::class, + ]), ProcessMaker\ImportExport\Extension::class, ProcessMaker\ImportExport\SignalHelper::class, ProcessMaker\Managers\MenuManager::class, @@ -157,6 +164,9 @@ ProcessMaker\Managers\ModelerManager::class, ProcessMaker\Managers\ScreenBuilderManager::class, Lavary\Menu\Menu::class, + ...($multitenancyEnabled ? [ + AnonymousUser::class, + ] : []), ], /* From 93bfb7ccf110ba14382c996248e1fb387587c206 Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Mon, 21 Sep 2026 06:23:11 -0700 Subject: [PATCH 3/5] Rebuild token guard on tenant switch --- .../Auth/PassportTokenGuardFactory.php | 36 +++++++++ ProcessMaker/Multitenancy/SwitchTenant.php | 31 +++++++- .../Providers/AuthServiceProvider.php | 19 +++++ .../Auth/PassportTokenGuardFactoryTest.php | 75 +++++++++++++++++++ .../Multitenancy/SwitchTenantTest.php | 70 +++++++++++++++++ 5 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 ProcessMaker/Auth/PassportTokenGuardFactory.php create mode 100644 tests/unit/ProcessMaker/Auth/PassportTokenGuardFactoryTest.php create mode 100644 tests/unit/ProcessMaker/Multitenancy/SwitchTenantTest.php diff --git a/ProcessMaker/Auth/PassportTokenGuardFactory.php b/ProcessMaker/Auth/PassportTokenGuardFactory.php new file mode 100644 index 0000000000..3e73bd67e6 --- /dev/null +++ b/ProcessMaker/Auth/PassportTokenGuardFactory.php @@ -0,0 +1,36 @@ +make(ResourceServer::class), + new PassportUserProvider(Auth::createUserProvider($config['provider']), $config['provider']), + $app->make(ClientRepository::class), + $app->make('encrypter'), + $app->make('request') + ), function (TokenGuard $guard) use ($app): void { + $app->refresh('request', $guard, 'setRequest'); + }); + } +} diff --git a/ProcessMaker/Multitenancy/SwitchTenant.php b/ProcessMaker/Multitenancy/SwitchTenant.php index e85c1421f7..ab08830519 100644 --- a/ProcessMaker/Multitenancy/SwitchTenant.php +++ b/ProcessMaker/Multitenancy/SwitchTenant.php @@ -6,7 +6,10 @@ use Illuminate\Contracts\Routing\UrlGenerator; use Illuminate\Support\Arr; use Illuminate\Support\Env; -use Monolog\Handler\RotatingFileHandler; +use Laravel\Passport\ApiTokenCookieFactory; +use Laravel\Passport\ClientRepository; +use League\OAuth2\Server\AuthorizationServer; +use League\OAuth2\Server\ResourceServer; use ProcessMaker\Application; use ProcessMaker\Multitenancy\Broadcasting\TenantAwareBroadcastManager; use Spatie\Multitenancy\Concerns\UsesMultitenancyConfig; @@ -65,7 +68,31 @@ public function forgetCurrent(): void // app key / encrypter $this->setConfig('app.key', $this->landlordConfig('app.key')); + $this->flushTenantSensitiveSingletons($app); + } + + /** + * Drop container instances that captured the previous tenant's APP_KEY or oauth keys. + * + * Passport's ResourceServer/AuthorizationServer and Encrypter are singletons. + * Under Octane they can outlive a tenant switch and keep signing or verifying + * laravel_token cookies with the wrong key (API 401s). + */ + private function flushTenantSensitiveSingletons(Application $app): void + { $app->forgetInstance('encrypter'); + $app->forgetInstance(ResourceServer::class); + $app->forgetInstance(AuthorizationServer::class); + $app->forgetInstance(ClientRepository::class); + $app->forgetInstance(ApiTokenCookieFactory::class); + + if ($app->resolved('auth.driver')) { + $app->forgetInstance('auth.driver'); + } + + if ($app->resolved('auth')) { + $app->make('auth')->forgetGuards(); + } } private function landlordConfig($key) @@ -130,7 +157,7 @@ private function overrideConfigs(Application $app, IsTenant $tenant) // app key / encrypter $landlordEncrypter = $app->make('encrypter'); $this->setConfig('app.key', $landlordEncrypter->decryptString($tenant->config['app.key'])); - $app->forgetInstance('encrypter'); + $this->flushTenantSensitiveSingletons($app); // Logging $this->setConfig('logging.channels.daily.path', storage_path('logs/processmaker.log')); diff --git a/ProcessMaker/Providers/AuthServiceProvider.php b/ProcessMaker/Providers/AuthServiceProvider.php index 36858333c5..6c8f110060 100644 --- a/ProcessMaker/Providers/AuthServiceProvider.php +++ b/ProcessMaker/Providers/AuthServiceProvider.php @@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Log; use Laravel\Passport\Passport; +use ProcessMaker\Auth\PassportTokenGuardFactory; use ProcessMaker\Events\TenantResolved; use ProcessMaker\Models\AnonymousUser; use ProcessMaker\Models\Media; @@ -64,6 +65,8 @@ public function boot() Passport::authorizationView('auth.oauth2.authorize'); + $this->registerPassportGuard(); + Gate::before(function ($user) { if ($user->is_administrator) { return true; @@ -113,4 +116,20 @@ public function register() $this->defineGates(); }); } + + /** + * Replace Passport's guard so TokenGuard is resolved from the current app. + * + * Passport binds the guard with the service provider's root container. + * Octane clones a sandbox per request and SwitchTenant swaps APP_KEY on + * that sandbox; the root worker still holds the landlord Encrypter. + */ + private function registerPassportGuard(): void + { + Auth::resolved(function ($auth): void { + $auth->extend('passport', function ($app, $name, array $config) { + return $app->make(PassportTokenGuardFactory::class)->make($app, $config); + }); + }); + } } diff --git a/tests/unit/ProcessMaker/Auth/PassportTokenGuardFactoryTest.php b/tests/unit/ProcessMaker/Auth/PassportTokenGuardFactoryTest.php new file mode 100644 index 0000000000..ef3448648b --- /dev/null +++ b/tests/unit/ProcessMaker/Auth/PassportTokenGuardFactoryTest.php @@ -0,0 +1,75 @@ +instance('encrypter', $encrypter); + $app->instance('request', Request::create('/')); + $app->instance(ResourceServer::class, Mockery::mock(ResourceServer::class)); + $app->instance(ClientRepository::class, Mockery::mock(ClientRepository::class)); + + $guard = (new PassportTokenGuardFactory())->make($app, [ + 'provider' => 'users', + ]); + + $this->assertInstanceOf(TokenGuard::class, $guard); + $this->assertSame($encrypter, $this->guardEncrypter($guard)); + } + + public function test_passport_guard_uses_the_auth_manager_application_encrypter(): void + { + $root = app(); + $auth = $root->make('auth'); + + $sandbox = clone $root; + $sandboxEncrypter = new Encrypter( + Encrypter::generateKey(config('app.cipher')), + config('app.cipher') + ); + $sandbox->instance('encrypter', $sandboxEncrypter); + $sandbox->instance('request', Request::create('/')); + $sandbox->instance(ResourceServer::class, Mockery::mock(ResourceServer::class)); + $sandbox->instance(ClientRepository::class, Mockery::mock(ClientRepository::class)); + + $auth->setApplication($sandbox); + $auth->forgetGuards(); + + try { + $guard = $auth->guard('api'); + + $this->assertInstanceOf(TokenGuard::class, $guard); + $this->assertSame($sandboxEncrypter, $this->guardEncrypter($guard)); + } finally { + $auth->setApplication($root); + $auth->forgetGuards(); + } + } + + private function guardEncrypter(TokenGuard $guard): object + { + $property = (new ReflectionClass($guard))->getProperty('encrypter'); + + return $property->getValue($guard); + } +} diff --git a/tests/unit/ProcessMaker/Multitenancy/SwitchTenantTest.php b/tests/unit/ProcessMaker/Multitenancy/SwitchTenantTest.php new file mode 100644 index 0000000000..db229a0af4 --- /dev/null +++ b/tests/unit/ProcessMaker/Multitenancy/SwitchTenantTest.php @@ -0,0 +1,70 @@ +instance(ResourceServer::class, Mockery::mock(ResourceServer::class)); + $app->instance(AuthorizationServer::class, Mockery::mock(AuthorizationServer::class)); + $app->instance(ClientRepository::class, Mockery::mock(ClientRepository::class)); + + $auth = $app->make('auth'); + $auth->guard('web'); + + $previousEncrypter = $app->make('encrypter'); + $tenantKey = 'base64:' . base64_encode(Encrypter::generateKey(config('app.cipher'))); + + $tenant = new Tenant(); + $tenant->id = 999001; + $tenant->domain = 'tenant-999001.test'; + $tenant->database = config('database.connections.processmaker.database'); + $tenant->config = [ + 'app.url' => config('app.url'), + 'app.key' => Crypt::encryptString($tenantKey), + ]; + + $switch = new SwitchTenant(); + + try { + $switch->makeCurrent($tenant); + + $this->assertArrayNotHasKey(ResourceServer::class, $this->containerInstances($app)); + $this->assertArrayNotHasKey(AuthorizationServer::class, $this->containerInstances($app)); + $this->assertArrayNotHasKey(ClientRepository::class, $this->containerInstances($app)); + $this->assertSame([], $this->authGuards($auth)); + $this->assertNotSame($previousEncrypter, $app->make('encrypter')); + } finally { + $switch->forgetCurrent(); + } + } + + private function containerInstances($app): array + { + $property = (new ReflectionObject($app))->getProperty('instances'); + + return $property->getValue($app); + } + + private function authGuards($auth): array + { + $property = (new ReflectionObject($auth))->getProperty('guards'); + + return $property->getValue($auth); + } +} From c1ebd2de42d5ef8cdc326ffedf1d00d359368805 Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Mon, 21 Sep 2026 06:45:51 -0700 Subject: [PATCH 4/5] Disable realtime output by default to speed things up --- phpunit.xml | 1 + tests/Extensions/RealTimeOutputExtension.php | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/phpunit.xml b/phpunit.xml index 6d8310e71c..218d88983a 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -34,6 +34,7 @@ + diff --git a/tests/Extensions/RealTimeOutputExtension.php b/tests/Extensions/RealTimeOutputExtension.php index 8a317305b7..df9d13316c 100644 --- a/tests/Extensions/RealTimeOutputExtension.php +++ b/tests/Extensions/RealTimeOutputExtension.php @@ -29,6 +29,11 @@ class RealTimeOutputExtension implements Extension public function bootstrap(Configuration $configuration, Facade $facade, ParameterCollection $parameters): void { + $enabled = getenv('PHPUNIT_REALTIME_OUTPUT'); + if (!filter_var($enabled === false ? 'false' : $enabled, FILTER_VALIDATE_BOOLEAN)) { + return; + } + $facade->registerSubscriber(new class implements PreparationStartedSubscriber { public function notify(PreparationStarted $event): void { From e75feae46e5ed21b129eca4d963ee8e567c6cad5 Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Mon, 21 Sep 2026 11:58:45 -0700 Subject: [PATCH 5/5] Fix broadcasting auth for multitenant octane --- .../TenantAwareBroadcastManager.php | 18 ++- .../TenantAwarePusherBroadcaster.php | 94 +++++++++--- ProcessMaker/Multitenancy/SwitchTenant.php | 7 - .../Providers/BroadcastServiceProvider.php | 28 +++- .../settings/components/SettingsListing.vue | 2 +- .../TenantAwarePusherBroadcasterTest.php | 135 ++++++++++++++++++ .../Multitenancy/SwitchTenantTest.php | 50 +++++-- 7 files changed, 287 insertions(+), 47 deletions(-) create mode 100644 tests/unit/ProcessMaker/Multitenancy/Broadcasting/TenantAwarePusherBroadcasterTest.php diff --git a/ProcessMaker/Multitenancy/Broadcasting/TenantAwareBroadcastManager.php b/ProcessMaker/Multitenancy/Broadcasting/TenantAwareBroadcastManager.php index c2570bc129..d827a8ddff 100644 --- a/ProcessMaker/Multitenancy/Broadcasting/TenantAwareBroadcastManager.php +++ b/ProcessMaker/Multitenancy/Broadcasting/TenantAwareBroadcastManager.php @@ -6,16 +6,14 @@ class TenantAwareBroadcastManager extends BroadcastManager { - private int $tenantId; - - public function __construct($app, int $tenantId) - { - parent::__construct($app); - $this->tenantId = $tenantId; - } - - public function createPusherDriver($config) + /** + * Create an instance of the driver. + * + * @param array $config + * @return \Illuminate\Contracts\Broadcasting\Broadcaster + */ + protected function createPusherDriver(array $config) { - return new TenantAwarePusherBroadcaster($this->pusher($config), $this->tenantId); + return new TenantAwarePusherBroadcaster($this->pusher($config), $config['jsonp'] ?? false); } } diff --git a/ProcessMaker/Multitenancy/Broadcasting/TenantAwarePusherBroadcaster.php b/ProcessMaker/Multitenancy/Broadcasting/TenantAwarePusherBroadcaster.php index bb5fafcc63..aa78b073e6 100644 --- a/ProcessMaker/Multitenancy/Broadcasting/TenantAwarePusherBroadcaster.php +++ b/ProcessMaker/Multitenancy/Broadcasting/TenantAwarePusherBroadcaster.php @@ -3,41 +3,99 @@ namespace ProcessMaker\Multitenancy\Broadcasting; use Illuminate\Broadcasting\Broadcasters\PusherBroadcaster; -use Pusher\Pusher; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; class TenantAwarePusherBroadcaster extends PusherBroadcaster { - private int $tenantId; + /** + * Authenticate the incoming request for a given channel. + * + * Channel callbacks are registered without a tenant prefix (once per Octane + * worker). Incoming Echo channels are prefixed, so strip the current + * tenant's prefix before matching. Pusher still signs the original name. + * + * @param \Illuminate\Http\Request $request + * @return mixed + * + * @throws AccessDeniedHttpException + */ + public function auth($request) + { + $channelName = $this->normalizeChannelName($request->channel_name); + $channelName = $this->unprefixTenantChannel($channelName); + + if (empty($request->channel_name) || + ($this->isGuardedChannel($request->channel_name) && + !$this->retrieveUser($request, $channelName))) { + throw new AccessDeniedHttpException; + } - public function __construct(Pusher $pusher, int $tenantId) + return parent::verifyUserCanAccessChannel( + $request, $channelName + ); + } + + /** + * @param array $channels + * @return array + */ + protected function formatChannels(array $channels) { - parent::__construct($pusher); - $this->tenantId = $tenantId; + return array_map(function ($channel) { + return $this->prefixTenantChannel((string) $channel); + }, $channels); } - public function channel($channel, $callback, $options = []) + private function currentTenantId(): ?int { - $channel = "tenant_{$this->tenantId}.{$channel}"; + $tenant = app()->bound('currentTenant') ? app('currentTenant') : null; - return parent::channel($channel, $callback, $options); + return $tenant?->id ? (int) $tenant->id : null; } - protected function formatChannels(array $channels) + private function tenantPrefix(): ?string { - $channels = array_map(function ($channel) { - $channel = (string) $channel; - if ($this->tenantId) { - // Check if channel starts with "private-" - if (str_starts_with($channel, 'private-')) { - return "private-tenant_{$this->tenantId}." . substr($channel, 8); // Remove "private-" prefix and add tenant before the rest + $tenantId = $this->currentTenantId(); + + return $tenantId ? "tenant_{$tenantId}." : null; + } + + private function prefixTenantChannel(string $channel): string + { + $prefix = $this->tenantPrefix(); + if ($prefix === null) { + return $channel; + } + + foreach (['private-encrypted-', 'private-', 'presence-'] as $guardPrefix) { + if (str_starts_with($channel, $guardPrefix)) { + $name = substr($channel, strlen($guardPrefix)); + if (str_starts_with($name, $prefix)) { + return $channel; } - return "tenant_{$this->tenantId}.{$channel}"; + return $guardPrefix . $prefix . $name; } + } + if (str_starts_with($channel, $prefix)) { return $channel; - }, $channels); + } + + return $prefix . $channel; + } + + private function unprefixTenantChannel(string $channelName): string + { + $prefix = $this->tenantPrefix(); + if ($prefix === null) { + return $channelName; + } + + if (!str_starts_with($channelName, $prefix)) { + throw new AccessDeniedHttpException; + } - return $channels; + return substr($channelName, strlen($prefix)); } } diff --git a/ProcessMaker/Multitenancy/SwitchTenant.php b/ProcessMaker/Multitenancy/SwitchTenant.php index ab08830519..cd35cd353c 100644 --- a/ProcessMaker/Multitenancy/SwitchTenant.php +++ b/ProcessMaker/Multitenancy/SwitchTenant.php @@ -2,7 +2,6 @@ namespace ProcessMaker\Multitenancy; -use Illuminate\Broadcasting\BroadcastManager; use Illuminate\Contracts\Routing\UrlGenerator; use Illuminate\Support\Arr; use Illuminate\Support\Env; @@ -11,7 +10,6 @@ use League\OAuth2\Server\AuthorizationServer; use League\OAuth2\Server\ResourceServer; use ProcessMaker\Application; -use ProcessMaker\Multitenancy\Broadcasting\TenantAwareBroadcastManager; use Spatie\Multitenancy\Concerns\UsesMultitenancyConfig; use Spatie\Multitenancy\Contracts\IsTenant; use Spatie\Multitenancy\Tasks\SwitchTenantTask; @@ -43,11 +41,6 @@ public function makeCurrent(IsTenant $tenant): void request()->headers->set('host', $tenant->domain); $this->overrideConfigs($app, $tenant); - - // Extend BroadcastManager to our custom implementation that prefixes the channel names with the tenant id. - $app->extend(BroadcastManager::class, function ($manager, $app) use ($tenant) { - return new TenantAwareBroadcastManager($app, $tenant->id); - }); } /** diff --git a/ProcessMaker/Providers/BroadcastServiceProvider.php b/ProcessMaker/Providers/BroadcastServiceProvider.php index 233252f282..17b5176608 100644 --- a/ProcessMaker/Providers/BroadcastServiceProvider.php +++ b/ProcessMaker/Providers/BroadcastServiceProvider.php @@ -2,8 +2,12 @@ namespace ProcessMaker\Providers; +use Illuminate\Broadcasting\BroadcastManager; +use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract; +use Illuminate\Contracts\Broadcasting\Factory as BroadcastingFactory; use Illuminate\Support\Facades\Broadcast; use Illuminate\Support\ServiceProvider; +use ProcessMaker\Multitenancy\Broadcasting\TenantAwareBroadcastManager; class BroadcastServiceProvider extends ServiceProvider { @@ -14,7 +18,29 @@ class BroadcastServiceProvider extends ServiceProvider */ public function boot() { - Broadcast::routes(['middleware'=>['web', 'auth:anon']]); + if (config('app.multitenancy')) { + $this->useTenantAwareBroadcastManager(); + } + + Broadcast::routes(['middleware' => ['web', 'auth:anon']]); require base_path('routes/channels.php'); } + + /** + * Replace Laravel's deferred BroadcastManager after it has registered, so + * channel callbacks stay on one driver instance for the life of the Octane + * worker. Tenant prefixes are applied at auth/broadcast time instead. + */ + private function useTenantAwareBroadcastManager(): void + { + $this->app->make(BroadcastManager::class); + + $manager = new TenantAwareBroadcastManager($this->app); + $this->app->instance(BroadcastManager::class, $manager); + $this->app->instance(BroadcastingFactory::class, $manager); + $this->app->forgetInstance(BroadcasterContract::class); + + Broadcast::clearResolvedInstance(BroadcastManager::class); + Broadcast::clearResolvedInstance(BroadcastingFactory::class); + } } diff --git a/resources/js/admin/settings/components/SettingsListing.vue b/resources/js/admin/settings/components/SettingsListing.vue index fd2ae5c596..1fbaeb1f57 100644 --- a/resources/js/admin/settings/components/SettingsListing.vue +++ b/resources/js/admin/settings/components/SettingsListing.vue @@ -374,7 +374,7 @@ export default { dataProvider(context, callback) { this.filter = ''; this.pmql = ''; - if (this.searchQuery.isPMQL()) { + if (isPMQL.call(this.searchQuery || "")) { this.pmql = this.searchQuery; } else { this.filter = this.searchQuery; diff --git a/tests/unit/ProcessMaker/Multitenancy/Broadcasting/TenantAwarePusherBroadcasterTest.php b/tests/unit/ProcessMaker/Multitenancy/Broadcasting/TenantAwarePusherBroadcasterTest.php new file mode 100644 index 0000000000..89c6b9a2a5 --- /dev/null +++ b/tests/unit/ProcessMaker/Multitenancy/Broadcasting/TenantAwarePusherBroadcasterTest.php @@ -0,0 +1,135 @@ +create(); + $this->setCurrentTenantId(4); + + $pusher = Mockery::mock(Pusher::class); + $pusher->shouldReceive('authorizeChannel') + ->once() + ->with('private-tenant_4.ProcessMaker.Models.User.' . $user->id, '1.234') + ->andReturn(json_encode(['auth' => 'app-key:signature'])); + + $broadcaster = $this->broadcasterWithUserChannel($pusher); + + $response = $broadcaster->auth($this->authRequest( + 'private-tenant_4.ProcessMaker.Models.User.' . $user->id, + $user + )); + + $this->assertSame(['auth' => 'app-key:signature'], $response); + } + + public function test_auth_rejects_channel_for_a_different_tenant(): void + { + $user = User::factory()->create(); + $this->setCurrentTenantId(4); + + $broadcaster = $this->broadcasterWithUserChannel(Mockery::mock(Pusher::class)); + + $this->expectException(AccessDeniedHttpException::class); + + $broadcaster->auth($this->authRequest( + 'private-tenant_9.ProcessMaker.Models.User.' . $user->id, + $user + )); + } + + public function test_auth_rejects_unprefixed_channel_when_tenant_is_current(): void + { + $user = User::factory()->create(); + $this->setCurrentTenantId(4); + + $broadcaster = $this->broadcasterWithUserChannel(Mockery::mock(Pusher::class)); + + $this->expectException(AccessDeniedHttpException::class); + + $broadcaster->auth($this->authRequest( + 'private-ProcessMaker.Models.User.' . $user->id, + $user + )); + } + + public function test_format_channels_prefixes_private_and_presence_names(): void + { + $this->setCurrentTenantId(4); + $broadcaster = new TenantAwarePusherBroadcaster(Mockery::mock(Pusher::class)); + + $this->assertSame( + [ + 'private-tenant_4.ProcessMaker.Models.User.1', + 'presence-tenant_4.room', + 'tenant_4.open-channel', + ], + $this->formatChannels($broadcaster, [ + 'private-ProcessMaker.Models.User.1', + 'presence-room', + 'open-channel', + ]) + ); + } + + public function test_format_channels_does_not_double_prefix(): void + { + $this->setCurrentTenantId(4); + $broadcaster = new TenantAwarePusherBroadcaster(Mockery::mock(Pusher::class)); + + $this->assertSame( + ['private-tenant_4.ProcessMaker.Models.User.1'], + $this->formatChannels($broadcaster, ['private-tenant_4.ProcessMaker.Models.User.1']) + ); + } + + private function broadcasterWithUserChannel(Pusher $pusher): TenantAwarePusherBroadcaster + { + $broadcaster = new TenantAwarePusherBroadcaster($pusher); + $broadcaster->channel('ProcessMaker.Models.User.{id}', function ($user, $id) { + return (int) $user->id === (int) $id; + }); + + return $broadcaster; + } + + private function authRequest(string $channelName, User $user): Request + { + $request = Request::create('/broadcasting/auth', 'POST', [ + 'socket_id' => '1.234', + 'channel_name' => $channelName, + ]); + $request->setUserResolver(fn () => $user); + + return $request; + } + + private function setCurrentTenantId(int $id): void + { + app()->instance('currentTenant', (object) ['id' => $id]); + } + + /** + * @param array $channels + * @return array + */ + private function formatChannels(TenantAwarePusherBroadcaster $broadcaster, array $channels): array + { + $method = new ReflectionMethod($broadcaster, 'formatChannels'); + + return $method->invoke($broadcaster, $channels); + } +} diff --git a/tests/unit/ProcessMaker/Multitenancy/SwitchTenantTest.php b/tests/unit/ProcessMaker/Multitenancy/SwitchTenantTest.php index db229a0af4..22a33b5ed4 100644 --- a/tests/unit/ProcessMaker/Multitenancy/SwitchTenantTest.php +++ b/tests/unit/ProcessMaker/Multitenancy/SwitchTenantTest.php @@ -4,6 +4,7 @@ namespace Tests\Unit\ProcessMaker\Multitenancy; +use Illuminate\Broadcasting\BroadcastManager; use Illuminate\Encryption\Encrypter; use Illuminate\Support\Facades\Crypt; use Laravel\Passport\ClientRepository; @@ -17,6 +18,28 @@ class SwitchTenantTest extends TestCase { + public function test_make_current_keeps_broadcast_manager_and_channel_callbacks(): void + { + $app = app(); + $manager = $app->make(BroadcastManager::class); + $manager->connection()->channel('ProcessMaker.Models.User.{id}', function ($user, $id) { + return (int) $user->id === (int) $id; + }); + + $switch = new SwitchTenant(); + + try { + $switch->makeCurrent($this->fakeTenant()); + + $this->assertSame($manager, $app->make(BroadcastManager::class)); + $this->assertTrue( + $app->make(BroadcastManager::class)->connection()->getChannels()->has('ProcessMaker.Models.User.{id}') + ); + } finally { + $switch->forgetCurrent(); + } + } + public function test_make_current_flushes_passport_singletons_and_auth_guards(): void { $app = app(); @@ -28,16 +51,7 @@ public function test_make_current_flushes_passport_singletons_and_auth_guards(): $auth->guard('web'); $previousEncrypter = $app->make('encrypter'); - $tenantKey = 'base64:' . base64_encode(Encrypter::generateKey(config('app.cipher'))); - - $tenant = new Tenant(); - $tenant->id = 999001; - $tenant->domain = 'tenant-999001.test'; - $tenant->database = config('database.connections.processmaker.database'); - $tenant->config = [ - 'app.url' => config('app.url'), - 'app.key' => Crypt::encryptString($tenantKey), - ]; + $tenant = $this->fakeTenant(); $switch = new SwitchTenant(); @@ -54,6 +68,22 @@ public function test_make_current_flushes_passport_singletons_and_auth_guards(): } } + private function fakeTenant(): Tenant + { + $tenantKey = 'base64:' . base64_encode(Encrypter::generateKey(config('app.cipher'))); + + $tenant = new Tenant(); + $tenant->id = 999001; + $tenant->domain = 'tenant-999001.test'; + $tenant->database = config('database.connections.processmaker.database'); + $tenant->config = [ + 'app.url' => config('app.url'), + 'app.key' => Crypt::encryptString($tenantKey), + ]; + + return $tenant; + } + private function containerInstances($app): array { $property = (new ReflectionObject($app))->getProperty('instances');