From 9b14de34dc64daa56b21491a0d54dd9c3e9b496d Mon Sep 17 00:00:00 2001 From: albertlast Date: Fri, 18 Sep 2026 14:58:07 +0200 Subject: [PATCH] Lets mods declare the services they use, and admins withdraw them A package says in its package-info.xml which services it provides and which it wants to use. The package manager shows those declarations among the install actions and records them when the package is installed, so Package Manager > Service Access can list what every installed mod does with services and take that access away again without uninstalling anything. Each package's factories are handed an accessor limited to what the package declared, so asking for anything else fails where it happens and says which package asked. Which container does the work stays inside SMF\Infrastructure: everything else is given has() and get(). Co-Authored-By: Claude Opus 5 Signed-off-by: Mathias Albert --- Languages/en_US/Admin.php | 1 + Languages/en_US/Packages.php | 15 ++ Sources/Actions/Admin/ACP.php | 3 + Sources/Infrastructure/PackageServices.php | 149 +++++++++++ .../Infrastructure/ServiceAccessException.php | 26 ++ .../ServiceNotFoundException.php | 26 ++ Sources/Infrastructure/ServiceRegistry.php | 235 ++++++++++++++++++ Sources/Infrastructure/Services.php | 143 +++++++++++ Sources/PackageManager/PackageManager.php | 109 +++++++- Sources/PackageManager/PackageUtils.php | 15 +- Themes/default/Packages.template.php | 89 +++++++ tests/Unit/PackageServicesTest.php | 80 ++++++ tests/Unit/ServicesTest.php | 98 ++++++++ 13 files changed, 987 insertions(+), 2 deletions(-) create mode 100644 Sources/Infrastructure/PackageServices.php create mode 100644 Sources/Infrastructure/ServiceAccessException.php create mode 100644 Sources/Infrastructure/ServiceNotFoundException.php create mode 100644 Sources/Infrastructure/ServiceRegistry.php create mode 100644 Sources/Infrastructure/Services.php create mode 100644 tests/Unit/PackageServicesTest.php create mode 100644 tests/Unit/ServicesTest.php diff --git a/Languages/en_US/Admin.php b/Languages/en_US/Admin.php index d50cd2913e..af26c03e7c 100644 --- a/Languages/en_US/Admin.php +++ b/Languages/en_US/Admin.php @@ -740,6 +740,7 @@ $txt['installed_packages'] = 'Installed Packages'; $txt['package_file_perms'] = 'File Permissions'; $txt['package_settings'] = 'Options'; +$txt['package_services'] = 'Service Access'; $txt['themeadmin_admin_title'] = 'Manage and Install'; $txt['themeadmin_list_title'] = 'Theme Settings'; $txt['themeadmin_reset_title'] = 'Member Options'; diff --git a/Languages/en_US/Packages.php b/Languages/en_US/Packages.php index b79dc422b9..631344c346 100644 --- a/Languages/en_US/Packages.php +++ b/Languages/en_US/Packages.php @@ -155,6 +155,21 @@ $txt['package_install_options'] = 'Installation Options'; $txt['package_install_options_desc'] = 'Set various options for how the package manager installs modifications, including backups and FTP access'; + +$txt['package_services'] = 'Service Access'; +$txt['package_services_desc'] = 'See which services each installed modification provides and uses, and withdraw that access'; +$txt['package_services_info'] = 'Services are the parts of the forum a modification asks to work with. A modification declares them in its package, you approve them when you install it, and you can withdraw them here without uninstalling anything. This describes what a modification asks for; it does not stop a badly behaved one from reaching the forum in other ways.'; +$txt['package_services_none'] = 'No installed modification provides or uses any service.'; +$txt['package_service_provides'] = 'Provides'; +$txt['package_service_uses'] = 'Uses'; +$txt['package_services_status'] = 'Access'; +$txt['package_services_granted'] = 'Allowed'; +$txt['package_services_revoked'] = 'Withdrawn'; +$txt['package_services_grant'] = 'Allow'; +$txt['package_services_revoke'] = 'Withdraw'; +$txt['package_services_not_registered_revoked'] = 'not available, access withdrawn'; +$txt['package_services_not_registered_taken'] = 'not available, another modification provides this service'; +$txt['package_services_core'] = 'Services provided by SMF'; $txt['package_install_options_ftp_why'] = 'Using the package manager’s FTP functionality is the easiest way to avoid having to manually chmod the files writable through FTP yourself for the package manager to work.
Here you can set the default values for some fields.'; $txt['package_install_options_ftp_server'] = 'FTP Server'; $txt['package_install_options_ftp_port'] = 'Port'; diff --git a/Sources/Actions/Admin/ACP.php b/Sources/Actions/Admin/ACP.php index 76aa3b572c..a57a479a08 100644 --- a/Sources/Actions/Admin/ACP.php +++ b/Sources/Actions/Admin/ACP.php @@ -145,6 +145,9 @@ class ACP implements ActionInterface, Routable 'options' => [ 'label' => 'package_settings', ], + 'services' => [ + 'label' => 'package_services', + ], ], ], 'search' => [ diff --git a/Sources/Infrastructure/PackageServices.php b/Sources/Infrastructure/PackageServices.php new file mode 100644 index 0000000000..6261bf22ae --- /dev/null +++ b/Sources/Infrastructure/PackageServices.php @@ -0,0 +1,149 @@ + $name, + 'provides' => array_values(array_unique($provides)), + 'uses' => array_values(array_unique($uses)), + 'granted' => true, + ]; + + self::save($manifests); + } + + /** + * Forgets a package, which is what uninstalling it means here. + * + * @param string $package_id The package's ID. + */ + public static function forget(string $package_id): void + { + $manifests = self::all(); + + if (!isset($manifests[$package_id])) { + return; + } + + unset($manifests[$package_id]); + + self::save($manifests); + } + + /** + * Allows or refuses a package the services it asked for. + * + * A package that has been refused keeps its entry, so the administrator can + * see what it wanted and can change their mind. + * + * @param string $package_id The package's ID. + * @param bool $granted Whether the package may use services. + */ + public static function setGranted(string $package_id, bool $granted): void + { + $manifests = self::all(); + + if (!isset($manifests[$package_id])) { + return; + } + + $manifests[$package_id]['granted'] = $granted; + + self::save($manifests); + } + + /************************* + * Internal static methods + *************************/ + + /** + * Writes the manifests back to the settings. + * + * @param array $manifests The manifests of every package. + */ + protected static function save(array $manifests): void + { + Config::updateModSettings([ + 'package_services' => $manifests === [] ? '' : Utils::jsonEncode($manifests), + ]); + } +} diff --git a/Sources/Infrastructure/ServiceAccessException.php b/Sources/Infrastructure/ServiceAccessException.php new file mode 100644 index 0000000000..3754df18e3 --- /dev/null +++ b/Sources/Infrastructure/ServiceAccessException.php @@ -0,0 +1,26 @@ +container = new Container(); + + $this->addCoreServices(); + $this->addPackageServices(); + } + + /** + * Gets the accessor SMF itself uses, which reaches every service. + * + * @return Services The accessor. + */ + public function services(): Services + { + return Services::forCore($this->container); + } + + /** + * Gets who provided each registered service. + * + * @return array Provider names, keyed by service ID. + */ + public function getProviders(): array + { + return $this->providers; + } + + /** + * Gets the services that were declared but not registered. + * + * @return array The package and the reason, keyed by service ID. + */ + public function getRejected(): array + { + return $this->rejected; + } + + /****************** + * Internal methods + ******************/ + + /** + * Registers the services that SMF itself provides. + */ + protected function addCoreServices(): void + { + foreach (require __DIR__ . '/ServicesList.php' as $id => $config) { + $method = ($config['shared'] ?? false) ? 'addShared' : 'add'; + + $this->container->$method($id)->addArguments($config['arguments'] ?? []); + + $this->providers[$id] = 'SMF'; + } + } + + /** + * Registers the services that installed packages provide. + * + * Each package's factories are handed an accessor that reaches the services + * the package declared and no others, so a factory asking for something the + * administrator never approved fails where it happens. + */ + protected function addPackageServices(): void + { + foreach (PackageServices::all() as $package_id => $manifest) { + $provided = array_column($manifest['provides'] ?? [], 'id'); + + if (empty($manifest['granted'])) { + foreach ($provided as $id) { + $this->rejected[$id] = ['package' => $manifest['name'], 'reason' => 'revoked']; + } + + continue; + } + + // A package may always use what it provides itself. + $services = Services::forPackage( + $this->container, + $manifest['name'], + array_merge($manifest['uses'] ?? [], $provided), + ); + + foreach ($manifest['provides'] ?? [] as $service) { + // Whoever got there first keeps the name, rather than a later + // package quietly replacing a service other code is using. + if (isset($this->providers[$service['id']])) { + $this->rejected[$service['id']] = ['package' => $manifest['name'], 'reason' => 'taken']; + + $this->logProblem(\sprintf( + '%s provides the service %s, which %s already provides.', + $manifest['name'], + $service['id'], + $this->providers[$service['id']], + )); + + continue; + } + + $this->container->addShared( + $service['id'], + fn(): object => $this->build($service, $services), + ); + + $this->providers[$service['id']] = $manifest['name']; + } + } + } + + /** + * Builds one service from the factory a package declared for it. + * + * The accessor is passed to the factory as its argument rather than bound to + * it, so a factory can be any callable the package likes. + * + * @param array $service The service's ID, factory and file. + * @param Services $services The accessor the factory may use. + * @throws ServiceAccessException If the factory cannot be used. + * @return object The service. + */ + protected function build(array $service, Services $services): object + { + if (!empty($service['file'])) { + $file = strtr($service['file'], [ + '$boarddir' => Config::$boarddir, + '$sourcedir' => Config::$sourcedir, + ]); + + // A package's own files live in the forum; anything else is not ours to load. + if (!str_starts_with(realpath($file) ?: '', realpath(Config::$boarddir) ?: Config::$boarddir)) { + throw new ServiceAccessException(\sprintf( + 'The factory file for %s is outside the forum: %s', + $service['id'], + $service['file'], + )); + } + + require_once $file; + } + + if (!\is_callable($service['factory'])) { + throw new ServiceAccessException(\sprintf( + 'The factory for %s cannot be called: %s', + $service['id'], + $service['factory'], + )); + } + + $made = \call_user_func($service['factory'], $services); + + if (!\is_object($made)) { + throw new ServiceAccessException(\sprintf( + 'The factory for %s did not return an object.', + $service['id'], + )); + } + + return $made; + } + + /** + * Records a problem with a service without stopping the page. + * + * @param string $message What went wrong. + */ + protected function logProblem(string $message): void + { + ErrorHandler::log($message, 'general'); + } +} diff --git a/Sources/Infrastructure/Services.php b/Sources/Infrastructure/Services.php new file mode 100644 index 0000000000..1038f5b59e --- /dev/null +++ b/Sources/Infrastructure/Services.php @@ -0,0 +1,143 @@ +isGranted($id) && $this->container->has($id); + } + + /** + * Gets a service. + * + * @param string $id The service identifier. + * @throws ServiceAccessException If this consumer has no access to it. + * @throws ServiceNotFoundException If nothing provides it. + * @return object The service. + */ + public function get(string $id): object + { + if (!$this->isGranted($id)) { + throw new ServiceAccessException(\sprintf( + '%s asked for the service %s, which it did not declare.', + $this->consumer, + $id, + )); + } + + try { + return $this->container->get($id); + } catch (NotFoundExceptionInterface $e) { + // Whichever container is underneath, what comes back out is ours. + throw new ServiceNotFoundException(\sprintf( + 'Nothing provides the service %s.', + $id, + ), 0, $e); + } + } + + /** + * Gets the name of whoever these services are for. + * + * @return string The consumer's name. + */ + public function getConsumer(): string + { + return $this->consumer; + } + + /*********************** + * Public static methods + ***********************/ + + /** + * Creates the accessor that SMF itself uses. + * + * @param ContainerInterface $container The container holding the services. + * @return self An accessor with access to every service. + */ + public static function forCore(ContainerInterface $container): self + { + return new self($container, 'SMF', ['*']); + } + + /** + * Creates the accessor for one package. + * + * @param ContainerInterface $container The container holding the services. + * @param string $package_name The package's name, as the admin knows it. + * @param array $granted The service IDs the package may use. + * @return self An accessor limited to those services. + */ + public static function forPackage(ContainerInterface $container, string $package_name, array $granted): self + { + return new self($container, $package_name, $granted); + } + + /****************** + * Internal methods + ******************/ + + /** + * Whether this consumer may have a service. + * + * @param string $id The service identifier. + * @return bool True if it was granted. + */ + private function isGranted(string $id): bool + { + return \in_array('*', $this->granted, true) || \in_array($id, $this->granted, true); + } +} diff --git a/Sources/PackageManager/PackageManager.php b/Sources/PackageManager/PackageManager.php index 5dd14b043b..e778f4651f 100644 --- a/Sources/PackageManager/PackageManager.php +++ b/Sources/PackageManager/PackageManager.php @@ -18,6 +18,8 @@ use SMF\Db\DatabaseApi as Db; use SMF\EmailAddress; use SMF\ErrorHandler; +use SMF\Infrastructure\PackageServices; +use SMF\Infrastructure\ServiceRegistry; use SMF\IntegrationHook; use SMF\ItemList; use SMF\Lang; @@ -26,6 +28,7 @@ use SMF\Parser; use SMF\Sapi; use SMF\Security; +use SMF\SecurityToken; use SMF\Theme; use SMF\Time; use SMF\User; @@ -58,6 +61,7 @@ class PackageManager 'uninstall2' => 'install', 'options' => 'options', 'perms' => 'permissions', + 'services' => 'serviceAccess', 'examine' => 'examineFile', 'showoperations' => 'showOperations', @@ -136,6 +140,9 @@ public function execute(): void 'options' => [ 'description' => Lang::getTxt('package_install_options_desc', file: 'Packages'), ], + 'services' => [ + 'description' => Lang::getTxt('package_services_desc', file: 'Packages'), + ], ], ]; @@ -619,6 +626,28 @@ public function installTest(): void 'type' => Lang::getTxt($action['reverse'] ? 'execute_hook_remove' : 'execute_hook_add', file: 'Packages'), 'action' => Lang::getTxt('execute_hook_action' . ($action['reverse'] ? '_inverse' : ''), ['hook' => Utils::htmlspecialchars($action['hook'])], file: 'Packages'), ]; + } elseif ($action['type'] == 'service') { + $action['description'] = Lang::getTxt($action['id'] === '' || $action['factory'] === '' ? 'package_action_failure' : 'package_action_success', file: 'Packages'); + + if ($action['id'] === '' || $action['factory'] === '') { + Utils::$context['has_failure'] = true; + } + + $thisAction = [ + 'type' => Lang::getTxt('package_service_provides', file: 'Packages'), + 'action' => Utils::htmlspecialchars($action['id']), + ]; + } elseif ($action['type'] == 'uses-service') { + $action['description'] = Lang::getTxt($action['id'] === '' ? 'package_action_failure' : 'package_action_success', file: 'Packages'); + + if ($action['id'] === '') { + Utils::$context['has_failure'] = true; + } + + $thisAction = [ + 'type' => Lang::getTxt('package_service_uses', file: 'Packages'), + 'action' => Utils::htmlspecialchars($action['id']), + ]; } elseif ($action['type'] == 'credits') { $thisAction = [ 'type' => Lang::getTxt('execute_credits_add', file: 'Packages'), @@ -735,7 +764,7 @@ public function installTest(): void continue; } - if (!\in_array($action['type'], ['hook', 'credits'])) { + if (!\in_array($action['type'], ['hook', 'credits', 'service', 'uses-service'])) { if (Utils::$context['uninstalling']) { $file = \in_array($action['type'], ['remove-dir', 'remove-file']) ? $action['filename'] : Config::$packagesdir . '/temp/' . Utils::$context['base_path'] . $action['filename']; } else { @@ -1079,6 +1108,9 @@ public function install(): void // @todo Make a log of any errors that occurred and output them? + $provides_services = []; + $uses_services = []; + if (!empty($install_log)) { $failed_steps = []; $failed_count = 0; @@ -1144,6 +1176,14 @@ public function install(): void 'copyright' => $action['copyright'], 'title' => $action['title'], ]; + } elseif ($action['type'] == 'service' && $action['id'] !== '' && $action['factory'] !== '') { + $provides_services[] = [ + 'id' => $action['id'], + 'factory' => $action['factory'], + 'file' => $action['include_file'], + ]; + } elseif ($action['type'] == 'uses-service' && $action['id'] !== '') { + $uses_services[] = $action['id']; } elseif ($action['type'] == 'hook' && isset($action['hook'], $action['function'])) { // Set the system to ignore hooks, but only if it wasn't changed before. if (!isset(Utils::$context['ignore_hook_errors'])) { @@ -1206,6 +1246,14 @@ public function install(): void PackageUtils::flushCache(); + // What this package does with services is settled by what the admin + // just agreed to, and goes away again when it is uninstalled. + if (Utils::$context['uninstalling']) { + PackageServices::forget($packageInfo['id']); + } else { + PackageServices::record($packageInfo['id'], $packageInfo['name'], $provides_services, $uses_services); + } + // See if this is already installed, and change it's state as required. $request = Db::$db->query( 'SELECT package_id, install_state, db_changes @@ -1727,6 +1775,65 @@ public function browse(): void /** * Used when a temp FTP access is needed to package functions */ + /** + * Shows what each installed package does with services, and lets the + * administrator take that access away. + * + * A package that has been refused keeps its entry here, so what it wanted + * stays visible and the decision can be changed back. + */ + public function serviceAccess(): void + { + if (isset($_GET['toggle'])) { + User::$me->checkSession('get'); + SecurityToken::validate('admin-services', 'get'); + + $package_id = Utils::htmlspecialcharsDecode($_GET['toggle']); + $manifest = PackageServices::get($package_id); + + if ($manifest !== []) { + PackageServices::setGranted($package_id, empty($manifest['granted'])); + } + + Utils::redirectexit('action=admin;area=packages;sa=services;' . Utils::$context['session_var'] . '=' . Utils::$context['session_id']); + } + + SecurityToken::create('admin-services', 'get'); + + // The registry says what actually happened to each declared service, + // which is not always what the package asked for. + $registry = new ServiceRegistry(); + $providers = $registry->getProviders(); + $rejected = $registry->getRejected(); + + $packages = []; + + foreach (PackageServices::all() as $package_id => $manifest) { + $provides = []; + + foreach ($manifest['provides'] ?? [] as $service) { + $provides[] = [ + 'id' => $service['id'], + 'registered' => ($providers[$service['id']] ?? '') === $manifest['name'], + 'reason' => $rejected[$service['id']]['reason'] ?? '', + ]; + } + + $packages[] = [ + 'id' => $package_id, + 'name' => $manifest['name'], + 'provides' => $provides, + 'uses' => $manifest['uses'] ?? [], + 'granted' => !empty($manifest['granted']), + ]; + } + + Utils::$context['package_services'] = $packages; + Utils::$context['core_services'] = array_keys(array_filter($providers, fn($provider) => $provider === 'SMF')); + Utils::$context['page_title'] = Lang::getTxt('package_services', file: 'Packages'); + Utils::$context['sub_template'] = 'service_access'; + } + public function options(): void { if (isset($_POST['save'])) { diff --git a/Sources/PackageManager/PackageUtils.php b/Sources/PackageManager/PackageUtils.php index e26e725ae3..392dfd9b3c 100644 --- a/Sources/PackageManager/PackageUtils.php +++ b/Sources/PackageManager/PackageUtils.php @@ -1255,6 +1255,19 @@ public static function parsePackageInfo(XmlArray &$packageXML, bool $testing_onl continue; } + // What the package provides as a service, and what it wants to use. + if ($actionType == 'service' || $actionType == 'uses-service') { + $return[] = [ + 'type' => $actionType, + 'id' => $action->exists('@id') ? $action->fetch('@id') : '', + 'factory' => $action->exists('@factory') ? $action->fetch('@factory') : '', + 'include_file' => $action->exists('@file') ? $action->fetch('@file') : '', + 'description' => '', + ]; + + continue; + } + if ($actionType == 'credits') { // quick check of any supplied url $url = $action->exists('@url') ? $action->fetch('@url') : ''; @@ -1451,7 +1464,7 @@ public static function parsePackageInfo(XmlArray &$packageXML, bool $testing_onl $not_done = [['type' => '!']]; foreach ($return as $action) { - if (\in_array($action['type'], ['modification', 'code', 'database', 'redirect', 'hook', 'credits'])) { + if (\in_array($action['type'], ['modification', 'code', 'database', 'redirect', 'hook', 'credits', 'service', 'uses-service'])) { $not_done[] = $action; } diff --git a/Themes/default/Packages.template.php b/Themes/default/Packages.template.php index 2dab82ce5d..79101e1452 100644 --- a/Themes/default/Packages.template.php +++ b/Themes/default/Packages.template.php @@ -1076,6 +1076,95 @@ function template_downloaded() /** * Installation options - FTP info and backup settings */ +/** + * Lists what each installed package does with services. + */ +function template_service_access() +{ + echo ' +
+

', Lang::getTxt('package_services', file: 'Packages'), '

+
+
+ ', Lang::getTxt('package_services_info', file: 'Packages'), ' +
'; + + if (empty(Utils::$context['package_services'])) { + echo ' +
+ ', Lang::getTxt('package_services_none', file: 'Packages'), ' +
'; + } + + foreach (Utils::$context['package_services'] as $package) { + echo ' +
+

', $package['name'], '

+
'; + + if (!empty($package['provides'])) { + echo ' +
', Lang::getTxt('package_service_provides', file: 'Packages'), '
+
+
    '; + + foreach ($package['provides'] as $service) { + echo ' +
  • ', $service['id'], $service['registered'] ? '' : ' (' . Lang::getTxt('package_services_not_registered_' . ($service['reason'] === 'taken' ? 'taken' : 'revoked'), file: 'Packages') . ')', '
  • '; + } + + echo ' +
+
'; + } + + if (!empty($package['uses'])) { + echo ' +
', Lang::getTxt('package_service_uses', file: 'Packages'), '
+
+
    '; + + foreach ($package['uses'] as $service) { + echo ' +
  • ', $service, '
  • '; + } + + echo ' +
+
'; + } + + echo ' +
', Lang::getTxt('package_services_status', file: 'Packages'), '
+
+ ', Lang::getTxt($package['granted'] ? 'package_services_granted' : 'package_services_revoked', file: 'Packages'), ' + + ', Lang::getTxt($package['granted'] ? 'package_services_revoke' : 'package_services_grant', file: 'Packages'), ' + +
+
+
'; + } + + if (!empty(Utils::$context['core_services'])) { + echo ' +
+

', Lang::getTxt('package_services_core', file: 'Packages'), '

+
+
+
    '; + + foreach (Utils::$context['core_services'] as $service) { + echo ' +
  • ', $service, '
  • '; + } + + echo ' +
+
'; + } +} + function template_install_options() { if (!empty(Utils::$context['saved_successful'])) { diff --git a/tests/Unit/PackageServicesTest.php b/tests/Unit/PackageServicesTest.php new file mode 100644 index 0000000000..6e25686421 --- /dev/null +++ b/tests/Unit/PackageServicesTest.php @@ -0,0 +1,80 @@ +assertSame([], PackageServices::all()); + } + + public function testItReadsWhatAPackageDeclared(): void + { + Config::$modSettings['package_services'] = json_encode([ + 'test:my_mod' => [ + 'name' => 'My Mod', + 'provides' => [['id' => 'MyMod\Repository', 'factory' => 'MyMod\Factory::make', 'file' => '']], + 'uses' => ['SMF\Services\ErrorHandlerService'], + 'granted' => true, + ], + ]); + + $manifest = PackageServices::get('test:my_mod'); + + $this->assertSame('My Mod', $manifest['name']); + $this->assertSame(['SMF\Services\ErrorHandlerService'], $manifest['uses']); + $this->assertTrue($manifest['granted']); + } + + public function testItKnowsNothingOfAPackageThatIsNotThere(): void + { + Config::$modSettings['package_services'] = json_encode([ + 'test:my_mod' => ['name' => 'My Mod', 'provides' => [], 'uses' => [], 'granted' => true], + ]); + + $this->assertSame([], PackageServices::get('test:other_mod')); + } + + /** + * A setting that has been mangled by hand leaves every package without + * access, which is the safe way round. + */ + public function testItTreatsAnUnreadableSettingAsNothingDeclared(): void + { + Config::$modSettings['package_services'] = 'not json at all'; + + $this->assertSame([], PackageServices::all()); + } + + public function testItHasNoAccessWhenThePackageWasRefused(): void + { + Config::$modSettings['package_services'] = json_encode([ + 'test:my_mod' => ['name' => 'My Mod', 'provides' => [], 'uses' => [], 'granted' => false], + ]); + + $this->assertFalse(PackageServices::get('test:my_mod')['granted']); + } + + /****************** + * Internal methods + ******************/ + + protected function tearDown(): void + { + // PHPUnit does not reset SMF's statics, so a key left here leaks. + unset(Config::$modSettings['package_services']); + } +} diff --git a/tests/Unit/ServicesTest.php b/tests/Unit/ServicesTest.php new file mode 100644 index 0000000000..e7a807a270 --- /dev/null +++ b/tests/Unit/ServicesTest.php @@ -0,0 +1,98 @@ +container(), 'My Mod', ['test.granted']); + + $this->assertInstanceOf(\stdClass::class, $services->get('test.granted')); + } + + public function testItRefusesAServiceThatWasNotGranted(): void + { + $services = Services::forPackage($this->container(), 'My Mod', ['test.granted']); + + $this->expectException(ServiceAccessException::class); + $this->expectExceptionMessage('My Mod asked for the service test.other'); + + $services->get('test.other'); + } + + /** + * The refusal has to come from the grants rather than from the container, + * or a package would learn what else is installed by asking for it. + */ + public function testItRefusesAnUngrantedServiceEvenWhenItIsRegistered(): void + { + $services = Services::forPackage($this->container(), 'My Mod', []); + + $this->assertFalse($services->has('test.granted')); + } + + public function testItSaysNoToAServiceNobodyProvides(): void + { + $services = Services::forPackage($this->container(), 'My Mod', ['test.missing']); + + $this->assertFalse($services->has('test.missing')); + } + + /** + * Which container SMF uses is nobody else's business, so its exceptions do + * not travel either. + */ + public function testItThrowsItsOwnExceptionWhenNothingProvidesTheService(): void + { + $services = Services::forCore($this->container()); + + $this->expectException(ServiceNotFoundException::class); + $this->expectExceptionMessage('Nothing provides the service test.missing'); + + $services->get('test.missing'); + } + + public function testSmfItselfReachesEveryService(): void + { + $services = Services::forCore($this->container()); + + $this->assertTrue($services->has('test.granted')); + $this->assertTrue($services->has('test.other')); + $this->assertSame('SMF', $services->getConsumer()); + } + + /****************** + * Internal methods + ******************/ + + /** + * Builds a container holding two services. + * + * @return Container The container. + */ + protected function container(): Container + { + $container = new Container(); + + foreach (['test.granted', 'test.other'] as $id) { + $container->addShared($id, static fn(): \stdClass => new \stdClass()); + } + + return $container; + } +}