diff --git a/ProcessMaker/Managers/PluginManager.php b/ProcessMaker/Managers/PluginManager.php index 761303414a..df85944b2d 100644 --- a/ProcessMaker/Managers/PluginManager.php +++ b/ProcessMaker/Managers/PluginManager.php @@ -92,9 +92,7 @@ public function install(string $repoUrl, ?string $branch = null, ?string $tag = $this->logRunning('Running plugin install command...', $repoName, $userId); $this->runCommand($installCommand, $repoName, $userId); - // Rebuild route cache so we pick up the new routes from the plugin - $this->logRunning('Rebuilding route cache...', $repoName, $userId); - $this->runCommand('route:cache', $repoName, $userId); + $this->rebuildRouteCache($repoName, $userId); $this->logDone('Plugin installed successfully', $repoName, $userId); } @@ -129,9 +127,7 @@ public function uninstall(string $pluginName, ?int $userId = null): void $this->logRunning('Removing plugin directory...', $pluginName, $userId); $this->deleteDirectory($pluginPath, $pluginName, $userId); - // Rebuild route cache so we remove the routes from the plugin - $this->logRunning('Rebuilding route cache...', $pluginName, $userId); - $this->runCommand('route:cache', $pluginName); + $this->rebuildRouteCache($pluginName, $userId); $this->logDone('Plugin uninstalled successfully', $pluginName, $userId); } @@ -141,7 +137,7 @@ private function runCommand(string $command, string $pluginName, ?int $userId = // Use absolute path: web SAPI (php-fpm) cwd is not the app root, so plain "artisan" fails. //TODO: Change artisan_binary() for base_path('artisan') $artisan = base_path('artisan'); - $result = Process::run(array_filter([ + $result = Process::env($this->artisanEnvironment())->run(array_filter([ php_binary(), $artisan, $command, @@ -161,6 +157,52 @@ private function runCommand(string $command, string $pluginName, ?int $userId = } } + /** + * Rebuild the tenant's route cache in a fresh artisan process. + * + * The current request already booted without the plugin's providers, so + * Artisan::call('route:cache') would persist stale routes. A subprocess + * with TENANT set loads plugins from the tenant storage path and writes + * the cache for that tenant. + */ + private function rebuildRouteCache(string $pluginName, ?int $userId = null): void + { + $this->logRunning('Rebuilding route cache...', $pluginName, $userId); + $this->runCommand('route:cache', $pluginName, $userId); + $this->reloadCachedRoutes(); + } + + /** + * Environment variables for artisan subprocesses started from a web request. + */ + private function artisanEnvironment(): array + { + $environment = []; + $tenant = app()->bound('currentTenant') ? app('currentTenant') : null; + + if (config('app.multitenancy') && $tenant) { + $environment['TENANT'] = (string) $tenant->id; + } + + return $environment; + } + + /** + * Load the freshly written route cache into the current process. + * + * Needed for Octane workers that keep the router in memory after + * `route:cache` finishes in a subprocess. + */ + private function reloadCachedRoutes(): void + { + $cachedRoutesPath = app()->getCachedRoutesPath(); + if (!is_file($cachedRoutesPath)) { + return; + } + + require $cachedRoutesPath; + } + /** * Install a plugin from a zip file path. * @@ -231,9 +273,7 @@ public function installFromZip(string $zipPath, ?int $userId = null): void $this->logRunning('Running plugin install command...', $repoName, $userId); $this->runCommand($installCommand, $repoName, $userId); - // Rebuild route cache - $this->logRunning('Rebuilding route cache...', $repoName, $userId); - $this->runCommand('route:cache', $repoName, $userId); + $this->rebuildRouteCache($repoName, $userId); $this->logDone('Plugin installed successfully', $repoName, $userId); } finally { @@ -271,6 +311,8 @@ public function toggle(string $pluginName, ?int $userId = null): bool throw new RuntimeException("Failed to disable plugin: {$pluginName}"); } + $this->rebuildRouteCache($pluginName, $userId); + return false; } @@ -283,6 +325,8 @@ public function toggle(string $pluginName, ?int $userId = null): bool throw new RuntimeException("Failed to enable plugin: {$pluginName}"); } + $this->rebuildRouteCache($enabledName, $userId); + return true; } diff --git a/ProcessMaker/Multitenancy/SwitchTenant.php b/ProcessMaker/Multitenancy/SwitchTenant.php index f2b955e7b0..3a0eb526cb 100644 --- a/ProcessMaker/Multitenancy/SwitchTenant.php +++ b/ProcessMaker/Multitenancy/SwitchTenant.php @@ -46,10 +46,11 @@ public function makeCurrent(IsTenant $tenant): void return new TenantAwareBroadcastManager($app, $tenant->id); }); - // Setup tenant-specific route cache. This is needed for plugins. - if ($app->routesAreCached()) { - Env::getRepository()->set('APP_ROUTES_CACHE', storage_path('routes-v7.php')); - } + // Configure the tenant-specific route cache before Laravel checks whether it + // exists. This lets `TENANT= php artisan route:cache` create or refresh + // the cache for a tenant after a plugin is installed, enabled, or disabled. + Env::getRepository()->set('APP_ROUTES_CACHE', storage_path('routes-v7.php')); + $this->reloadRouteCache($app); } /** @@ -61,6 +62,7 @@ public function forgetCurrent(): void { $app = app(); $app->useStoragePath(base_path('storage')); + Env::getRepository()->clear('APP_ROUTES_CACHE'); $this->setConfig('logging.channels.daily.path', storage_path('logs/processmaker.log')); $app->make('log')->reset(); @@ -90,6 +92,29 @@ private function setEnvironmentVariable($key, $value) $_ENV[$key] = $value; } + /** + * Reload the tenant's cached routes when the router can persist in memory. + * + * Octane keeps the router in memory between requests. The console and test + * environments may also initialize the router before a tenant is made + * current. Updating APP_ROUTES_CACHE alone is insufficient in those cases. + */ + private function reloadRouteCache(Application $app): void + { + if (!$app->routesAreCached() || !$this->shouldReinitializeRouter()) { + return; + } + + require $app->getCachedRoutesPath(); + } + + private function shouldReinitializeRouter(): bool + { + return isset($_SERVER['LARAVEL_OCTANE']) + || app()->runningInConsole() + || app()->runningUnitTests(); + } + private function overrideConfigs(Application $app, IsTenant $tenant) { $this->setEnvironmentVariable('APP_URL', $tenant->config['app.url']); diff --git a/resources/js/admin/plugins/Plugins.vue b/resources/js/admin/plugins/Plugins.vue index 7392176500..7345687373 100644 --- a/resources/js/admin/plugins/Plugins.vue +++ b/resources/js/admin/plugins/Plugins.vue @@ -459,7 +459,7 @@ const save = () => { data: { ...plugin }, }) .then(() => { - ProcessMaker.alert($t("The plugin was ") + verb + ".", "success"); + reloadAfterPluginChange($t("The plugin was ") + verb + "."); }) .catch((error) => { disabled.value = false; @@ -471,6 +471,11 @@ const save = () => { }); }; +const reloadAfterPluginChange = (message) => { + ProcessMaker.alert(message, "success", 5, true); + window.location.reload(); +}; + const resetValues = () => { title.value = $t("Create Plugin"); Object.assign(plugin, { @@ -526,8 +531,7 @@ const doToggle = (item) => { ProcessMaker.apiClient .patch(`/plugins/${item.name}/toggle`, { baseURL: "/api/1.0/" }) .then(() => { - ProcessMaker.alert($t("The plugin was toggled."), "success"); - fetch(); + reloadAfterPluginChange($t("The plugin was toggled.")); }); }; @@ -542,8 +546,7 @@ const doDelete = (item) => { ProcessMaker.apiClient .delete(`/plugins/${item.name}`, { baseURL: "/api/1.0/" }) .then(() => { - ProcessMaker.alert($t("The plugin was deleted."), "success"); - fetch(); + reloadAfterPluginChange($t("The plugin was deleted.")); }) .catch((error) => { const msg = _.get(error, "response.data.errors.delete.0"); diff --git a/tests/Managers/PluginManagerTest.php b/tests/Managers/PluginManagerTest.php index e2a4f0e0d6..f95d720aae 100644 --- a/tests/Managers/PluginManagerTest.php +++ b/tests/Managers/PluginManagerTest.php @@ -69,7 +69,7 @@ public function testInstallClonesRepositoryAndValidatesPlugin() Event::fake([PluginLog::class]); $manager = new PluginManager(); - $manager->install($repoUrl); + $manager->install($repoUrl, null, null, 1); Event::assertDispatched(PluginLog::class, function ($event) { return str_contains($event->message, 'installed successfully'); @@ -133,8 +133,12 @@ public function testUninstallRemovesPluginDirectory() Event::fake([PluginLog::class]); + Process::fake([ + '*' => Process::result('', exitCode: 0), + ]); + $manager = new PluginManager(); - $manager->uninstall($pluginName); + $manager->uninstall($pluginName, 1); $this->assertFalse(is_dir($pluginPath)); Event::assertDispatched(PluginLog::class, function ($event) { @@ -210,7 +214,7 @@ public function testListReturnsInstalledPlugins() $this->assertEquals('Second test plugin', $plugins[1]['description']); } - public function testListIgnoresPluginsStartingWithUnderscore() + public function testListIncludesDisabledPlugins() { $this->setUpTestPluginManager(); @@ -244,9 +248,51 @@ public function testListIgnoresPluginsStartingWithUnderscore() $manager = new PluginManager(); $plugins = $manager->list(); + $pluginsByName = collect($plugins)->keyBy('name'); + + $this->assertCount(2, $plugins); + $this->assertEquals('Enabled', $pluginsByName['valid-plugin']['enabled']); + $this->assertEquals('Disabled', $pluginsByName['hidden-plugin']['enabled']); + } + + public function testArtisanEnvironmentIncludesCurrentTenant() + { + config(['app.multitenancy' => true]); + $this->app->instance('currentTenant', (object) ['id' => 7]); + + $method = new \ReflectionMethod(PluginManager::class, 'artisanEnvironment'); + $environment = $method->invoke(new PluginManager()); + + $this->assertSame(['TENANT' => '7'], $environment); + } + + public function testToggleRebuildsRouteCache() + { + $this->setUpTestPluginManager(); + + $pluginName = 'toggle-plugin'; + $pluginPath = $this->pluginsDir . '/' . $pluginName; + $this->tempPluginDir = $this->pluginsDir . '/_' . $pluginName; + mkdir($pluginPath, 0755, true); + + Process::fake([ + '*' => Process::result('', exitCode: 0), + ]); + + $manager = new PluginManager(); + $enabled = $manager->toggle($pluginName); - $this->assertCount(1, $plugins); - $this->assertEquals('valid-plugin', $plugins[0]['name']); + $this->assertFalse($enabled); + $this->assertFalse(is_dir($pluginPath)); + $this->assertTrue(is_dir($this->pluginsDir . '/_' . $pluginName)); + + Process::assertRan(function ($process) { + $command = is_array($process->command) + ? implode(' ', $process->command) + : (string) $process->command; + + return str_contains($command, 'route:cache'); + }); } private function deleteDirectory($dir)