diff --git a/.composer-require-checker.json b/.composer-require-checker.json index 09556d7..66de5dd 100644 --- a/.composer-require-checker.json +++ b/.composer-require-checker.json @@ -14,7 +14,13 @@ "callable", "iterable", "void", - "object" + "object", + "attr", + "xlt", + "OpenEMR\\Common\\Crypto\\CryptoGen", + "OpenEMR\\Common\\Database\\QueryUtils", + "OpenEMR\\Events\\Globals\\GlobalsInitializedEvent", + "OpenEMR\\Services\\Globals\\GlobalSetting" ], "php-core-extensions": [ "Core", diff --git a/composer.json b/composer.json index 5e743b1..2fdf4de 100644 --- a/composer.json +++ b/composer.json @@ -26,11 +26,13 @@ "require": { "php": ">=8.2", "ext-filter": "*", + "symfony/event-dispatcher": "^6.4 || ^7.0", "symfony/http-foundation": "^6.4 || ^7.0", "symfony/yaml": "^6.4 || ^7.0" }, "require-dev": { "ergebnis/composer-normalize": "^2.44", + "openemr/openemr": ">=8.0.0 || 8.1.0.x-dev || dev-master", "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^11.0", "rector/rector": "^2.0", @@ -39,6 +41,59 @@ "suggest": { "google/cloud-secret-manager": "Required for gcp-secret-manager provider in _secrets block (^2.1)" }, + "repositories": [ + { + "type": "package", + "package": { + "name": "openemr/openemr", + "version": "8.0.0", + "autoload": { + "psr-4": { + "OpenEMR\\": "src/" + } + }, + "source": { + "type": "git", + "url": "https://github.com/openemr/openemr.git", + "reference": "v8_0_0" + } + } + }, + { + "type": "package", + "package": { + "name": "openemr/openemr", + "version": "8.1.0.x-dev", + "autoload": { + "psr-4": { + "OpenEMR\\": "src/" + } + }, + "source": { + "type": "git", + "url": "https://github.com/openemr/openemr.git", + "reference": "rel-810" + } + } + }, + { + "type": "package", + "package": { + "name": "openemr/openemr", + "version": "dev-master", + "autoload": { + "psr-4": { + "OpenEMR\\": "src/" + } + }, + "source": { + "type": "git", + "url": "https://github.com/openemr/openemr.git", + "reference": "master" + } + } + } + ], "minimum-stability": "stable", "prefer-stable": true, "autoload": { diff --git a/src/ConfigService.php b/src/ConfigService.php new file mode 100644 index 0000000..81f4193 --- /dev/null +++ b/src/ConfigService.php @@ -0,0 +1,40 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc. + */ +class ConfigService +{ + private const UPSERT_SQL = <<<'SQL' + INSERT INTO `globals` (`gl_name`, `gl_index`, `gl_value`) + VALUES (?, 0, ?) + ON DUPLICATE KEY UPDATE `gl_value` = ? + SQL; + + /** + * Save a setting to the globals table (plaintext). + */ + public function saveSetting(string $key, string $value): void + { + QueryUtils::sqlStatementThrowException(self::UPSERT_SQL, [$key, $value, $value]); + } + + /** + * Encrypt a value with CryptoGen and save it to the globals table. + */ + public function saveEncryptedSetting(string $key, string $value): void + { + $encrypted = (new CryptoGen())->encryptStandard($value); + $this->saveSetting($key, $encrypted); + } +} diff --git a/src/GlobalsRegistrar.php b/src/GlobalsRegistrar.php new file mode 100644 index 0000000..df75b88 --- /dev/null +++ b/src/GlobalsRegistrar.php @@ -0,0 +1,89 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc. + */ +class GlobalsRegistrar +{ + public function __construct( + private readonly ParameterBag $globalsBag, + ) { + } + + /** + * Subscribe to GlobalsInitializedEvent and register the module's globals section. + */ + public function register( + EventDispatcherInterface $eventDispatcher, + GlobalsSectionDescriptor $descriptor, + ): void { + $globalsBag = $this->globalsBag; + + $eventDispatcher->addListener( + GlobalsInitializedEvent::EVENT_HANDLE, + static function (GlobalsInitializedEvent $event) use ($descriptor, $globalsBag): void { + $service = $event->getGlobalsService(); + + $service->createSection($descriptor->sectionName); + + // Enable/disable toggle + $enableSetting = new GlobalSetting( + sprintf(xlt('Enable %s'), $descriptor->sectionName), + GlobalSetting::DATA_TYPE_BOOL, + '0', + sprintf(xlt('Enable or disable the %s module'), $descriptor->sectionName), + ); + $service->appendToSection( + $descriptor->sectionName, + $descriptor->enableKey, + $enableSetting, + ); + + // Settings page link + $webroot = $globalsBag->getString('webroot'); + $settingsPath = $webroot + . '/interface/modules/custom_modules/' . $descriptor->moduleDirName + . '/public/settings.php'; + + $linkSetting = new GlobalSetting( + xlt('Module Settings'), + GlobalSetting::DATA_TYPE_HTML_DISPLAY_SECTION, + '', + xlt('Link to the module settings page'), + ); + $linkSetting->addFieldOption( + GlobalSetting::DATA_TYPE_OPTION_RENDER_CALLBACK, + static function () use ($settingsPath, $descriptor): string { + $url = attr($settingsPath); + $label = xlt('Open Module Settings'); + $description = xlt($descriptor->settingsDescription); + return <<{$description}

+ {$label} + HTML; + }, + ); + $service->appendToSection( + $descriptor->sectionName, + $descriptor->enableKey . '_settings_link', + $linkSetting, + ); + }, + ); + } +} diff --git a/src/GlobalsSectionDescriptor.php b/src/GlobalsSectionDescriptor.php new file mode 100644 index 0000000..325ef9f --- /dev/null +++ b/src/GlobalsSectionDescriptor.php @@ -0,0 +1,28 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc. + */ +final readonly class GlobalsSectionDescriptor +{ + /** + * @param string $sectionName Display name for the globals section (e.g. "OpenCoreEMR Sinch Conversations") + * @param string $moduleDirName Module directory name under custom_modules/ (e.g. "oce-module-sinch-conversations") + * @param string $enableKey Globals key for the enable/disable toggle (e.g. "oce_sinch_conversations_enabled") + * @param string $settingsDescription Help text shown above the settings link + */ + public function __construct( + public string $sectionName, + public string $moduleDirName, + public string $enableKey, + public string $settingsDescription, + ) { + } +} diff --git a/tests/Mocks/MockCryptoGen.php b/tests/Mocks/MockCryptoGen.php new file mode 100644 index 0000000..f5d9784 --- /dev/null +++ b/tests/Mocks/MockCryptoGen.php @@ -0,0 +1,16 @@ +}> */ + private static array $queries = []; + + private static ?\Throwable $nextException = null; + + /** + * @param list $binds + */ + public static function sqlStatementThrowException(string $sql, array $binds = []): true + { + self::$queries[] = ['sql' => $sql, 'binds' => $binds]; + if (self::$nextException !== null) { + $e = self::$nextException; + self::$nextException = null; + throw $e; + } + return true; + } + + public static function setNextException(\Throwable $e): void + { + self::$nextException = $e; + } + + /** @return list}> */ + public static function getQueries(): array + { + return self::$queries; + } + + public static function reset(): void + { + self::$queries = []; + self::$nextException = null; + } +} diff --git a/tests/Mocks/openemr_functions.php b/tests/Mocks/openemr_functions.php new file mode 100644 index 0000000..445d1ee --- /dev/null +++ b/tests/Mocks/openemr_functions.php @@ -0,0 +1,20 @@ +service = new ConfigService(); + } + + protected function tearDown(): void + { + QueryUtils::reset(); + } + + public function testSaveSettingExecutesUpsert(): void + { + $this->service->saveSetting('oce_test_key', 'test_value'); + + $queries = QueryUtils::getQueries(); + $this->assertCount(1, $queries); + $this->assertStringContainsString('INSERT INTO `globals`', $queries[0]['sql']); + $this->assertStringContainsString('ON DUPLICATE KEY UPDATE', $queries[0]['sql']); + $this->assertSame(['oce_test_key', 'test_value', 'test_value'], $queries[0]['binds']); + } + + public function testSaveEncryptedSettingEncryptsBeforeSaving(): void + { + $this->service->saveEncryptedSetting('oce_secret_key', 'secret_value'); + + $queries = QueryUtils::getQueries(); + $this->assertCount(1, $queries); + $this->assertSame('oce_secret_key', $queries[0]['binds'][0]); + // MockCryptoGen uses base64; value is bound twice for INSERT and UPDATE + $encrypted = base64_encode('secret_value'); + $this->assertSame($encrypted, $queries[0]['binds'][1]); + $this->assertSame($encrypted, $queries[0]['binds'][2]); + } + + public function testSaveSettingPropagatesExceptions(): void + { + QueryUtils::setNextException(new \RuntimeException('DB error')); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('DB error'); + + $this->service->saveSetting('oce_test_key', 'value'); + } + + public function testMultipleSaveSettingCallsAreRecorded(): void + { + $this->service->saveSetting('key_a', 'val_a'); + $this->service->saveSetting('key_b', 'val_b'); + + $queries = QueryUtils::getQueries(); + $this->assertCount(2, $queries); + $this->assertSame(['key_a', 'val_a', 'val_a'], $queries[0]['binds']); + $this->assertSame(['key_b', 'val_b', 'val_b'], $queries[1]['binds']); + } +} diff --git a/tests/Unit/GlobalsRegistrarTest.php b/tests/Unit/GlobalsRegistrarTest.php new file mode 100644 index 0000000..d29b813 --- /dev/null +++ b/tests/Unit/GlobalsRegistrarTest.php @@ -0,0 +1,109 @@ +descriptor = new GlobalsSectionDescriptor( + sectionName: 'OpenCoreEMR Test Module', + moduleDirName: 'oce-module-test', + enableKey: 'oce_test_module_enabled', + settingsDescription: 'Configure test module options.', + ); + $this->globalsBag = new ParameterBag(['webroot' => '/openemr']); + } + + public function testRegisterAddsListener(): void + { + $dispatcher = new EventDispatcher(); + $registrar = new GlobalsRegistrar($this->globalsBag); + $registrar->register($dispatcher, $this->descriptor); + + $this->assertTrue($dispatcher->hasListeners(GlobalsInitializedEvent::EVENT_HANDLE)); + } + + protected function tearDown(): void + { + // Clean up global state written by GlobalsService::save() + // phpcs:ignore Squiz.PHP.GlobalKeyword.NotAllowed + global $GLOBALS_METADATA; + $GLOBALS_METADATA = null; + } + + /** + * Dispatch the event and return the captured globals metadata. + * + * @return array> + */ + private function dispatchAndCapture(): array + { + $dispatcher = new EventDispatcher(); + $registrar = new GlobalsRegistrar($this->globalsBag); + $registrar->register($dispatcher, $this->descriptor); + + $globalsService = new GlobalsService([], [], []); + $event = new GlobalsInitializedEvent($globalsService); + $dispatcher->dispatch($event, GlobalsInitializedEvent::EVENT_HANDLE); + + // GlobalsService::save() writes to global vars — use that to capture state + $globalsService->save(); + // phpcs:ignore Squiz.PHP.GlobalKeyword.NotAllowed + global $GLOBALS_METADATA; + /** @var array> $metadata */ + $metadata = $GLOBALS_METADATA ?? []; + return $metadata; + } + + public function testCreatesSectionWithEnableToggle(): void + { + $metadata = $this->dispatchAndCapture(); + + $this->assertArrayHasKey('OpenCoreEMR Test Module', $metadata); + + $section = $metadata['OpenCoreEMR Test Module']; + $this->assertArrayHasKey('oce_test_module_enabled', $section); + + /** @var list $enableEntry */ + $enableEntry = $section['oce_test_module_enabled']; + $this->assertSame('bool', $enableEntry[1]); + $this->assertSame('0', $enableEntry[2]); + } + + public function testCreatesSectionWithSettingsLink(): void + { + $metadata = $this->dispatchAndCapture(); + + $this->assertArrayHasKey('OpenCoreEMR Test Module', $metadata); + $section = $metadata['OpenCoreEMR Test Module']; + $this->assertArrayHasKey('oce_test_module_enabled_settings_link', $section); + + /** @var list $linkEntry */ + $linkEntry = $section['oce_test_module_enabled_settings_link']; + $this->assertSame('html_display_section', $linkEntry[1]); + + /** @var array $options */ + $options = $linkEntry[4]; + $this->assertArrayHasKey('render_callback', $options); + + $html = ($options['render_callback'])(); + $this->assertStringContainsString('oce-module-test', $html); + $this->assertStringContainsString('/openemr/interface/modules/custom_modules/', $html); + $this->assertStringContainsString('settings.php', $html); + $this->assertStringContainsString('Configure test module options.', $html); + } +} diff --git a/tests/Unit/GlobalsSectionDescriptorTest.php b/tests/Unit/GlobalsSectionDescriptorTest.php new file mode 100644 index 0000000..eb8c8e9 --- /dev/null +++ b/tests/Unit/GlobalsSectionDescriptorTest.php @@ -0,0 +1,26 @@ +assertSame('OpenCoreEMR Test Module', $descriptor->sectionName); + $this->assertSame('oce-module-test', $descriptor->moduleDirName); + $this->assertSame('oce_test_module_enabled', $descriptor->enableKey); + $this->assertSame('Configure API credentials and module options.', $descriptor->settingsDescription); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index d7a32e3..22dd046 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -7,3 +7,8 @@ // Suppress error_log output during tests ini_set('error_log', '/dev/null'); + +// Load mock classes to shadow real OpenEMR classes (must come after autoloader) +require_once __DIR__ . '/Mocks/MockQueryUtils.php'; +require_once __DIR__ . '/Mocks/MockCryptoGen.php'; +require_once __DIR__ . '/Mocks/openemr_functions.php';