From 36b70ba2ced1220585f3511524b46a8cd2b842e2 Mon Sep 17 00:00:00 2001 From: Jerry Padgett Date: Tue, 9 Sep 2025 00:55:25 -0400 Subject: [PATCH 001/112] Add Edit Event validation fix (#8876) allow duration to be 0 for all day event scheduling. --- interface/main/calendar/add_edit_event.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/interface/main/calendar/add_edit_event.php b/interface/main/calendar/add_edit_event.php index e31d3bb08f42..9115ba09a0f7 100644 --- a/interface/main/calendar/add_edit_event.php +++ b/interface/main/calendar/add_edit_event.php @@ -1067,8 +1067,8 @@ function set_display() { style_prefcat.display = ''; f.form_apptstatus.style.display = 'none'; f.form_prefcat.style.display = ''; - f.form_duration.disabled = true; - f.form_duration.value = ''; + f.form_duration.disabled = false; + f.form_duration.value = 0; document.getElementById('tdallday4').style.color = 'var(--gray)'; } else { style_prefcat.display = 'none'; @@ -1878,6 +1878,7 @@ function are_days_checked(){ * */ var collectvalidation = ; function validateform(event,valu){ + let allDay = document.getElementById('rballday1').checked; collectvalidation.form_hour = { numericality: { onlyInteger: true, @@ -1904,6 +1905,9 @@ function validateform(event,valu){ } }; + if ( allDay == true) { + collectvalidation.form_duration ={}; + } else { collectvalidation.form_duration = { numericality: { onlyInteger: true, @@ -1915,6 +1919,7 @@ function validateform(event,valu){ message: "Duration is required" } }; + } $('#form_save').attr('disabled', true); //Make sure if days_every_week is checked that at least one weekday is checked. From 498f029b36ff79562af6ee95dafea07f79047873 Mon Sep 17 00:00:00 2001 From: "Michael A. Smith" Date: Thu, 4 Sep 2025 22:11:08 -0400 Subject: [PATCH 002/112] fix(oeHttpRequest): client is static --- phpstan.github.neon | 4 ---- src/Common/Http/oeHttpRequest.php | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/phpstan.github.neon b/phpstan.github.neon index d60d5142ba2b..f08d2b3f7145 100644 --- a/phpstan.github.neon +++ b/phpstan.github.neon @@ -17480,10 +17480,6 @@ parameters: identifier: nullCoalesce.variable count: 1 path: src/Common/Http/HttpRestRequest.php - - message: '#^Non\-static access to static property OpenEMR\\Common\\Http\\oeHttp\:\:\$client\.$#' - identifier: staticProperty.nonStaticAccess - count: 1 - path: src/Common/Http/oeHttpRequest.php - message: '#^Variable \$test in isset\(\) always exists and is not nullable\.$#' identifier: isset.variable count: 1 diff --git a/src/Common/Http/oeHttpRequest.php b/src/Common/Http/oeHttpRequest.php index 9755895c4bb3..234b60fe18fd 100644 --- a/src/Common/Http/oeHttpRequest.php +++ b/src/Common/Http/oeHttpRequest.php @@ -171,7 +171,7 @@ public function send($method, $url, $options = ''): oeHttpResponse ]); } - return new oeHttpResponse($this->client->request($method, $url, $this->mergeOptions([ + return new oeHttpResponse(self::$client->request($method, $url, $this->mergeOptions([ 'query' => $this->parseQueryParams($url), ], $options))); } From 572ab2be3ec56808bd2823ffdd1736bf0db679bb Mon Sep 17 00:00:00 2001 From: steve waite Date: Tue, 9 Sep 2025 15:24:38 -0400 Subject: [PATCH 003/112] chore: eye form escaping and upgrade fix (#8847) * chore: eye form escaping * database fixes * remove database changes from eye mag functions * pst * eye form parent category * make room for new eye form folders in db.sql * eye form is parent category * re-number new folders * function to insert new categories * simplify sql statement for affected categories * root node and eye form fixes * rght --- .../forms/eye_mag/php/eye_mag_functions.php | 202 +++++++----------- phpstan.github.neon | 4 - sql/7_0_3-to-7_0_4_upgrade.sql | 3 + sql/database.sql | 18 +- src/Services/Utils/SQLUpgradeService.php | 45 ++++ 5 files changed, 131 insertions(+), 141 deletions(-) diff --git a/interface/forms/eye_mag/php/eye_mag_functions.php b/interface/forms/eye_mag/php/eye_mag_functions.php index 9e5cd0ed69be..6d506b94e8c8 100644 --- a/interface/forms/eye_mag/php/eye_mag_functions.php +++ b/interface/forms/eye_mag/php/eye_mag_functions.php @@ -1142,47 +1142,47 @@ function display_PRIOR_section($zone, $orig_id, $id_to_show, $pid, $report = '0'
 
-
+
 
 
 
 
 
-
+
 
 
 
 
 
-
+
 
-
+
 
 
 
 
-
+
 
 
 
 
-
+
 
 
 
-
+
 
 
 
-
+
 
 
 
-
+
 
 
@@ -1190,13 +1190,13 @@ function display_PRIOR_section($zone, $orig_id, $id_to_show, $pid, $report = '0'
 
 
 
-
+
 
 
-
+
 
 
-
+
 
 
 
@@ -1218,17 +1218,17 @@ function display_PRIOR_section($zone, $orig_id, $id_to_show, $pid, $report = '0'
 
-
-
-
-
+
+
+
+
 
 
 
-
-
-
-
+
+
+
+
 
@@ -1250,13 +1250,13 @@ function display_PRIOR_section($zone, $orig_id, $id_to_show, $pid, $report = '0'
 
 
 
-
+
 
 
-
+
 
 
-
+
 
 
 
@@ -1264,47 +1264,47 @@ function display_PRIOR_section($zone, $orig_id, $id_to_show, $pid, $report = '0'
 
 
-
+
 
 
 
-
+
 
 
 
-
+
 
 
 
-
+
 
 
 
 
-
+
 
 
 
 
-
+
-
+
 
 
 
 
 
-
+
 
 
 
 
 
-
+
 
@@ -1315,47 +1315,47 @@ function display_PRIOR_section($zone, $orig_id, $id_to_show, $pid, $report = '0'
 
-
+
 
 
 
 
 
-
+
 
 
 
 
 
-
+
 
-
+
 
 
 
 
-
+
 
 
 
 
-
+
 
 
 
-
+
 
 
 
-
+
 
 
 
-
+
 
 
@@ -1363,13 +1363,13 @@ function display_PRIOR_section($zone, $orig_id, $id_to_show, $pid, $report = '0'
 
 
 
-
+
 
 
-
+
 
 
-
+
 
 
 
@@ -1391,17 +1391,17 @@ function display_PRIOR_section($zone, $orig_id, $id_to_show, $pid, $report = '0'
 
-
-
-
-
+
+
+
+
 
 
 
-
-
-
-
+
+
+
+
 
@@ -1423,13 +1423,13 @@ function display_PRIOR_section($zone, $orig_id, $id_to_show, $pid, $report = '0'
 
 
 
-
+
 
 
-
+
 
 
-
+
 
 
 
@@ -1437,48 +1437,48 @@ function display_PRIOR_section($zone, $orig_id, $id_to_show, $pid, $report = '0'
 
 
-
+
 
 
 
-
+
 
 
 
-
+
 
 
 
-
+
 
 
 
 
-
+
 
 
 
 
-
+
 
-
+
 
 
 
 
 
-
+
 
 
 
 
 
-
+
 
@@ -3797,64 +3797,6 @@ function document_engine($pid) } } - if (!$zone['AntSeg Laser']) { - $sql = "select id from categories ORDER by id desc LIMIT 1"; - $last_row = sqlQuery($sql); - $counter = $last_row['id']; - $counter++; - $sql = "INSERT INTO `categories` ( `id`, `name`, `value`, `parent`, `aco_spec`, `codes`) - VALUES (?, 'AntSeg Laser', 'ANTSEG', '14', 'patients|docs', '');"; - sqlStatement($sql, [$counter]); - - $sql = "SELECT * from categories where id = ?"; - $sql2 = sqlStatement($sql, [$counter]); - while ($row1 = sqlFetchArray($sql2)) { - $categories[] = $row1; - $my_name[$row1['id']] = $row1['name']; - $children_names[$row1['parent'] ?? ''][] = $row1['name'] ?? ''; - $parent_name[$row1['name']] = $my_name[$row1['parent']]; - $zones[$row1['value']][] = $row1; - } - } - if (!$zone['Retina Laser']) { - $sql = "select id from categories ORDER by id desc LIMIT 1"; - $last_row = sqlQuery($sql); - $counter = $last_row['id']; - $counter++; - $sql = "INSERT INTO `categories` (`id`, `name`, `value`, `parent`, `aco_spec`, `codes`) - VALUES (?, 'Retina Laser', 'POSTSEG', '14', 'patients|docs', '');"; - sqlStatement($sql, [$counter]); - - $sql = "SELECT * from categories where id = ?"; - $sql2 = sqlStatement($sql, [$counter]); - while ($row1 = sqlFetchArray($sql2)) { - $categories[] = $row1; - $my_name[$row1['id']] = $row1['name']; - $children_names[$row1['parent'] ?? ''][] = $row1['name'] ?? ''; - $parent_name[$row1['name']] = $my_name[$row1['parent']]; - $zones[$row1['value']][] = $row1; - } - } - if (!$zone['Injections']) { - $sql = "select id from categories ORDER by id desc LIMIT 1"; - $last_row = sqlQuery($sql); - $counter = $last_row['id']; - $counter++; - $sql = "INSERT INTO `categories` (`id`, `name`, `value`, `parent`, `aco_spec`, `codes`) - VALUES (?, 'Injections', 'POSTSEG', '14', 'patients|docs', '');"; - sqlStatement($sql, [$counter]); - - $sql = "SELECT * from categories where id = ?"; - $sql2 = sqlStatement($sql, [$counter]); - while ($row1 = sqlFetchArray($sql2)) { - $categories[] = $row1; - $my_name[$row1['id']] = $row1['name']; - $children_names[$row1['parent'] ?? ''][] = $row1['name'] ?? ''; - $parent_name[$row1['name']] = $my_name[$row1['parent']]; - $zones[$row1['value']][] = $row1; - } - } - $query = "Select *, categories.name as cat_name from categories, documents, categories_to_documents @@ -5911,34 +5853,34 @@ function display_VisualAcuities($pid = 0): void foreach ($flip_priors_CC as $prior) { ?> - + - + - + - + - + - + - + - + diff --git a/phpstan.github.neon b/phpstan.github.neon index f08d2b3f7145..64960d282c5e 100644 --- a/phpstan.github.neon +++ b/phpstan.github.neon @@ -5944,10 +5944,6 @@ parameters: identifier: variable.undefined count: 1 path: interface/forms/eye_mag/php/eye_mag_functions.php - - message: '#^Variable \$zone might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: interface/forms/eye_mag/php/eye_mag_functions.php - message: '#^Variable \$va_dates might not be defined\.$#' identifier: variable.undefined count: 1 diff --git a/sql/7_0_3-to-7_0_4_upgrade.sql b/sql/7_0_3-to-7_0_4_upgrade.sql index 9f376824b0e9..8389fbc53c92 100644 --- a/sql/7_0_3-to-7_0_4_upgrade.sql +++ b/sql/7_0_3-to-7_0_4_upgrade.sql @@ -613,3 +613,6 @@ VALUES ('sdoh_instruments', 'hunger_vital_sign', 'Hunger Vital Sign (2-item)', 1 ('sdoh_instruments', 'ipv_hark', 'Intimate Partner Violence – HARK', 50, 'LOINC:76499-3', ''); #EndIf -- --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + +#IfEyeFormLaserCategoriesNeeded +#EndIf diff --git a/sql/database.sql b/sql/database.sql index 2ee18be77e97..b7d20a86b630 100644 --- a/sql/database.sql +++ b/sql/database.sql @@ -297,7 +297,7 @@ CREATE TABLE `categories` ( -- Inserting data for table `categories` -- -INSERT INTO `categories` VALUES (1, 'Categories', '', 0, 0, 61, 'patients|docs', ''); +INSERT INTO `categories` VALUES (1, 'Categories', '', 0, 0, 67, 'patients|docs', ''); INSERT INTO `categories` VALUES (2, 'Lab Report', '', 1, 1, 2, 'patients|docs', ''); INSERT INTO `categories` VALUES (3, 'Medical Record', '', 1, 3, 4, 'patients|docs', ''); INSERT INTO `categories` VALUES (4, 'Patient Information', '', 1, 5, 10, 'patients|demo', ''); @@ -310,7 +310,7 @@ INSERT INTO `categories` VALUES (10, 'Patient Photograph', '', 4, 8, 9, 'patient INSERT INTO `categories` VALUES (11, 'CCR', '', 1, 19, 20, 'patients|docs', ''); INSERT INTO `categories` VALUES (12, 'CCD', '', 1, 21, 22, 'patients|docs', 'LOINC:34133-9'); INSERT INTO `categories` VALUES (13, 'CCDA', '', 1, 23, 24, 'patients|docs', ''); -INSERT INTO `categories` VALUES (14, 'Eye Module', '', 1, 25, 50, 'patients|docs', ''); +INSERT INTO `categories` VALUES (14, 'Eye Module', '', 1, 25, 56, 'patients|docs', ''); INSERT INTO `categories` VALUES (15, 'Communication - Eye', '', 14, 26, 27, 'patients|docs', ''); INSERT INTO `categories` VALUES (16, 'Encounters - Eye', '', 14, 28, 29, 'patients|docs',''); INSERT INTO `categories` VALUES (17, 'Imaging - Eye', '', 14, 30, 49, 'patients|docs',''); @@ -323,11 +323,15 @@ INSERT INTO `categories` VALUES (23, 'Fundus - Eye', 'POSTSEG', 17, 41, 42, 'pat INSERT INTO `categories` VALUES (24, 'Radiology - Eye', 'NEURO', 17, 43, 44, 'patients|docs',''); INSERT INTO `categories` VALUES (25, 'VF - Eye', 'NEURO', 17, 45, 46, 'patients|docs',''); INSERT INTO `categories` VALUES (26, 'Drawings - Eye', '', 17, 47, 48, 'patients|docs',''); -INSERT INTO `categories` VALUES (27, 'Onsite Portal', '', 1, 51, 56, 'patients|docs',''); -INSERT INTO `categories` VALUES (28, 'Patient', '', 27, 52, 53, 'patients|docs',''); -INSERT INTO `categories` VALUES (29, 'Reviewed', '', 27, 54, 55, 'patients|docs','LOINC:LP173394-0'); -INSERT INTO `categories` VALUES (30, 'FHIR Export Document', '', 1, 57, 58, 'admin|super','LOINC:LP173421-1'); -INSERT INTO `categories` VALUES (31, 'Invoices', '', 1, 59, 60, 'encounters|coding', ''); +INSERT INTO `categories` VALUES (27, 'Onsite Portal', '', 1, 57, 62, 'patients|docs',''); +INSERT INTO `categories` VALUES (28, 'Patient', '', 27, 58, 59, 'patients|docs',''); +INSERT INTO `categories` VALUES (29, 'Reviewed', '', 27, 60, 61, 'patients|docs','LOINC:LP173394-0'); +INSERT INTO `categories` VALUES (30, 'FHIR Export Document', '', 1, 63, 64, 'admin|super','LOINC:LP173421-1'); +INSERT INTO `categories` VALUES (31, 'Invoices', '', 1, 65, 66, 'encounters|coding', ''); +INSERT INTO `categories` VALUES (32, 'AntSeg Laser - Eye', '', 14, 50, 51, 'patients|docs', ''); +INSERT INTO `categories` VALUES (33, 'Retina Laser - Eye', '', 14, 52, 53, 'patients|docs', ''); +INSERT INTO `categories` VALUES (34, 'Injections - Eye', '', 14, 54, 55, 'patients|docs', ''); + -- -------------------------------------------------------- -- diff --git a/src/Services/Utils/SQLUpgradeService.php b/src/Services/Utils/SQLUpgradeService.php index 1156b0d30b9d..7acdecaa7e8e 100644 --- a/src/Services/Utils/SQLUpgradeService.php +++ b/src/Services/Utils/SQLUpgradeService.php @@ -657,6 +657,51 @@ function upgradeFromSqlFile($filename, $path = '') if ($skipping) { $this->echo("

$skip_msg $line

\n"); } + } elseif (preg_match('/^#IfEyeFormLaserCategoriesNeeded/', $line)) { + $eyeFormCategoryParent = sqlQueryNoLog("SELECT `id`, `rght` FROM `categories` WHERE `name` = 'Eye Module'"); + $eyeFormAntSegLaser = sqlQueryNoLog("SELECT `id` FROM `categories` WHERE `name` = 'AntSeg Laser - Eye'"); + if (!empty($eyeFormCategoryParent) && empty($eyeFormAntSegLaser)) { + $this->echo("

Inserting eye module laser categories.

\n"); + $this->flush_echo(); + // update non eye form categories affected + sqlStatementNoLog("UPDATE `categories` SET `rght` = `rght` + 6 WHERE `rght` >= ?", [$eyeFormCategoryParent['rght']]); + sqlStatementNoLog("UPDATE `categories` SET `lft` = `lft` + 6 WHERE `lft` >= ?", [$eyeFormCategoryParent['rght']]); + // insert new eye form categories + sqlStatementNoLog( + "INSERT INTO `categories` (`id`, `name`, `value`, `parent`, `lft`, `rght`, `aco_spec`, `codes`) select (select MAX(id) from categories) + 1, 'AntSeg Laser - Eye', '', ?, ?, ?, 'patients|docs', ''", + [ + $eyeFormCategoryParent['id'], + $eyeFormCategoryParent['rght'], + $eyeFormCategoryParent['rght'] + 1 + ] + ); + sqlStatementNoLog( + "INSERT INTO `categories` (`id`, `name`, `value`, `parent`, `lft`, `rght`, `aco_spec`, `codes`) select (select MAX(id) from categories) + 1, 'Retina Laser - Eye', '', ?, ?, ?, 'patients|docs', ''", + [ + $eyeFormCategoryParent['id'], + $eyeFormCategoryParent['rght'] + 2, + $eyeFormCategoryParent['rght'] + 3 + ] + ); + sqlStatementNoLog( + "INSERT INTO `categories` (`id`, `name`, `value`, `parent`, `lft`, `rght`, `aco_spec`, `codes`) select (select MAX(id) from categories) + 1, 'Injections - Eye', '', ?, ?, ?, 'patients|docs', ''", + [ + $eyeFormCategoryParent['id'], + $eyeFormCategoryParent['rght'] + 4, + $eyeFormCategoryParent['rght'] + 5 + ] + ); + // update root node + sqlStatementNoLog("UPDATE `categories` SET `rght` = (SELECT MAX(`rght`) FROM `categories`) + 1 WHERE `id` = 1"); + $this->echo("

Completed conversion of categories for eye form insertion.

\n"); + $this->flush_echo(); + $skipping = false; + } else { + $skipping = true; + } + if ($skipping) { + $this->echo("

$skip_msg $line

\n"); + } } elseif (preg_match('/^#EndIf/', $line)) { $skipping = false; } From 98a06226abc73eff7acb18853259870b50439461 Mon Sep 17 00:00:00 2001 From: "Michael A. Smith" Date: Tue, 9 Sep 2025 13:58:47 -0400 Subject: [PATCH 004/112] Revert "chore(deps): bump jszip and dwv" This reverts commit 51e04f46bb298e1570d4080bef79f557c45bf04c. --- package-lock.json | 72 ++++++++++++++++++++++++++++++++++++----------- package.json | 2 +- 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index e05d285731aa..7e2854ec5ca4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "datatables.net-scroller-jqui": "2.4.3", "dompurify": "3.2.6", "dropzone": "5.9.3", - "dwv": "0.35.1", + "dwv": "0.27.1", "flot": "4.2.6", "hotkeys-js": "3.13.15", "i18next": "24.2.3", @@ -6305,19 +6305,56 @@ } }, "node_modules/dwv": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/dwv/-/dwv-0.35.1.tgz", - "integrity": "sha512-Y3ZbvTihXUYVcn7DzwXaXpABuNLiw4BOi5Xzcurb0nxf/9ooPfYnyxahVrGEuCSRG1LySsNzRuPtZDvL6F0J0w==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/dwv/-/dwv-0.27.1.tgz", + "integrity": "sha512-HwqoyizxXeIVgeX7bhyQjXLY8Ez+h8Qe03rfsTevYSM+RMPWOOoykxKXMVtRrUX+sXUamXNN4Xhs0MhqRz3wOg==", "license": "GPL-3.0", "dependencies": { - "jszip": "^3.10.1", - "konva": "~9.3.20", - "magic-wand-tool": "~1.1.7" - }, - "engines": { - "node": ">= 14.0.0" + "i18next": "~12.1.0", + "i18next-browser-languagedetector": "~3.0.0", + "i18next-xhr-backend": "~2.0.0", + "jszip": "~3.2.0", + "konva": "~2.6.0", + "magic-wand-js": "~1.0.0" + } + }, + "node_modules/dwv/node_modules/i18next": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-12.1.0.tgz", + "integrity": "sha512-AexmwGkKxwKfo5fGeXTWEY4xqzRPigQ1S/0InOUUVziGO54cd4fKyYK8ED1Thx9fd+WA3fRSZ+1iekvFQMbsFw==", + "license": "MIT" + }, + "node_modules/dwv/node_modules/i18next-browser-languagedetector": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-3.0.3.tgz", + "integrity": "sha512-1YuAogyQap0J6N4kM+6gAjZ6T7QWrp3xZCmSs0QedkNmgAKhj7FiQlCviHKl3IwbM6zJNgft4D7UDPWb1dTCMQ==", + "license": "MIT" + }, + "node_modules/dwv/node_modules/i18next-xhr-backend": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/i18next-xhr-backend/-/i18next-xhr-backend-2.0.1.tgz", + "integrity": "sha512-CP0XPjJsTE4hY1rM1KXFYo63Ib61EBLEcTvMDyJwr0vs9p/UTuA3ENCmzSs9+ghZgWSjdOigc0oUERHaxctbsQ==", + "deprecated": "replaced by i18next-http-backend", + "license": "MIT" + }, + "node_modules/dwv/node_modules/jszip": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.2.2.tgz", + "integrity": "sha512-NmKajvAFQpbg3taXQXr/ccS2wcucR1AZ+NtyWp2Nq7HHVsXhcJFR8p0Baf32C2yVvBylFWVeKf+WI2AnvlPhpA==", + "license": "(MIT OR GPL-3.0)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "set-immediate-shim": "~1.0.1" } }, + "node_modules/dwv/node_modules/konva": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/konva/-/konva-2.6.0.tgz", + "integrity": "sha512-LCOoavICTD9PYoAqtWo8sbxYtCiXdgEeY7vj/Sq8b2bwFmrQr9Ak0RkD4/jxAf5fcUQRL5e1zPLyfRpVndp20A==", + "license": "MIT" + }, "node_modules/each-props": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/each-props/-/each-props-1.3.2.tgz", @@ -11927,12 +11964,6 @@ "integrity": "sha512-zeS0NbcwAW+msgzwPQjKZaIc0VaTyDQgMIV5Yzs7J+3EvBPoyNbLz1jfWUsSltZRtcFKem1qzeOec8/sXPfWCQ==", "license": "MIT" }, - "node_modules/magic-wand-tool": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/magic-wand-tool/-/magic-wand-tool-1.1.7.tgz", - "integrity": "sha512-S4rHzCs/bAp7nhQGKeg+McWuqrdyZKpnu8Ahd8AU7NzuLTm/Hh8tkpv1tW91Kmm59foIrXzip1d+P9NDoyxrZA==", - "license": "MIT" - }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -16202,6 +16233,15 @@ "node": ">= 0.4" } }, + "node_modules/set-immediate-shim": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz", + "integrity": "sha512-Li5AOqrZWCVA2n5kryzEmqai6bKSIvpz5oUJHPVj6+dsbD3X1ixtsY5tEnsaNpH3pFAHmG8eIHUrtEtohrg+UQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/set-proto": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", diff --git a/package.json b/package.json index 187039a75da0..d25c8769c12c 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "datatables.net-scroller-jqui": "2.4.3", "dompurify": "3.2.6", "dropzone": "5.9.3", - "dwv": "0.35.1", + "dwv": "0.27.1", "flot": "4.2.6", "hotkeys-js": "3.13.15", "i18next": "24.2.3", From 8b86c1789a06db4808edf40612ef575f375ea2ca Mon Sep 17 00:00:00 2001 From: "Michael A. Smith" Date: Tue, 9 Sep 2025 14:20:10 -0400 Subject: [PATCH 005/112] fix(export_labworks): rename Add to avoid conflict --- custom/export_labworks.php | 126 ++++++++++++++++++------------------- 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/custom/export_labworks.php b/custom/export_labworks.php index a775968ffbc3..5ac092d2f6bf 100644 --- a/custom/export_labworks.php +++ b/custom/export_labworks.php @@ -34,7 +34,7 @@ $out = ""; // Add a string to output with some basic sanitizing. -function Add($field) +function custom_labworks_Add($field) { return "^" . trim(str_replace(array("\r", "\n", "\t"), " ", $field)); } @@ -147,84 +147,84 @@ function mydie($msg): void // Patient Section. // $out .= $pid; // patient id - $out .= Add($row['pubpid']); // chart number - $out .= Add($row['lname']); // last name - $out .= Add($row['fname']); // first name - $out .= Add(substr($row['mname'], 0, 1)); // middle initial - $out .= Add(""); // alias - $out .= Add(Digits($row['ss'])); // ssn - $out .= Add(LWDate($row['DOB'])); // dob - $out .= Add(Sex($row['sex'])); // gender - $out .= Add(""); // notes - $out .= Add($row['street']); // address 1 - $out .= Add(""); // address2 - $out .= Add($row['city']); // city - $out .= Add($row['state']); // state - $out .= Add($row['postal_code']); // zip - $out .= Add(Digits($row['phone_home'])); // home phone + $out .= custom_labworks_Add($row['pubpid']); // chart number + $out .= custom_labworks_Add($row['lname']); // last name + $out .= custom_labworks_Add($row['fname']); // first name + $out .= custom_labworks_Add(substr($row['mname'], 0, 1)); // middle initial + $out .= custom_labworks_Add(""); // alias + $out .= custom_labworks_Add(Digits($row['ss'])); // ssn + $out .= custom_labworks_Add(LWDate($row['DOB'])); // dob + $out .= custom_labworks_Add(Sex($row['sex'])); // gender + $out .= custom_labworks_Add(""); // notes + $out .= custom_labworks_Add($row['street']); // address 1 + $out .= custom_labworks_Add(""); // address2 + $out .= custom_labworks_Add($row['city']); // city + $out .= custom_labworks_Add($row['state']); // state + $out .= custom_labworks_Add($row['postal_code']); // zip + $out .= custom_labworks_Add(Digits($row['phone_home'])); // home phone // Guarantor Section. OpenEMR does not have guarantors so we use the primary // insurance subscriber if there is one, otherwise the patient. // if (trim($row['lname1'])) { - $out .= Add($row['lname1']); - $out .= Add($row['fname1']); - $out .= Add(substr($row['mname1'], 0, 1)); - $out .= Add($row['sstreet1']); - $out .= Add(""); - $out .= Add($row['scity1']); - $out .= Add($row['sstate1']); - $out .= Add($row['szip1']); + $out .= custom_labworks_Add($row['lname1']); + $out .= custom_labworks_Add($row['fname1']); + $out .= custom_labworks_Add(substr($row['mname1'], 0, 1)); + $out .= custom_labworks_Add($row['sstreet1']); + $out .= custom_labworks_Add(""); + $out .= custom_labworks_Add($row['scity1']); + $out .= custom_labworks_Add($row['sstate1']); + $out .= custom_labworks_Add($row['szip1']); } else { - $out .= Add($row['lname']); - $out .= Add($row['fname']); - $out .= Add(substr($row['mname'], 0, 1)); - $out .= Add($row['street']); - $out .= Add(""); - $out .= Add($row['city']); - $out .= Add($row['state']); - $out .= Add($row['postal_code']); + $out .= custom_labworks_Add($row['lname']); + $out .= custom_labworks_Add($row['fname']); + $out .= custom_labworks_Add(substr($row['mname'], 0, 1)); + $out .= custom_labworks_Add($row['street']); + $out .= custom_labworks_Add(""); + $out .= custom_labworks_Add($row['city']); + $out .= custom_labworks_Add($row['state']); + $out .= custom_labworks_Add($row['postal_code']); } // Primary Insurance Section. // - $out .= Add($row['provider1']); - $out .= Add($row['name1']); - $out .= Add($row['street11']); - $out .= Add($row['street21']); - $out .= Add($row['city1']); - $out .= Add($row['state1']); - $out .= Add($row['zip1']); - $out .= Add(""); - $out .= Add(InsType($row['instype1'])); - $out .= Add($row['fname1'] . " " . $row['lname1']); - $out .= Add(ucfirst($row['relationship1'])); - $out .= Add($row['group1']); - $out .= Add($row['policy1']); + $out .= custom_labworks_Add($row['provider1']); + $out .= custom_labworks_Add($row['name1']); + $out .= custom_labworks_Add($row['street11']); + $out .= custom_labworks_Add($row['street21']); + $out .= custom_labworks_Add($row['city1']); + $out .= custom_labworks_Add($row['state1']); + $out .= custom_labworks_Add($row['zip1']); + $out .= custom_labworks_Add(""); + $out .= custom_labworks_Add(InsType($row['instype1'])); + $out .= custom_labworks_Add($row['fname1'] . " " . $row['lname1']); + $out .= custom_labworks_Add(ucfirst($row['relationship1'])); + $out .= custom_labworks_Add($row['group1']); + $out .= custom_labworks_Add($row['policy1']); // Secondary Insurance Section. // - $out .= Add($row['provider2']); - $out .= Add($row['name2']); - $out .= Add($row['street12']); - $out .= Add($row['street22']); - $out .= Add($row['city2']); - $out .= Add($row['state2']); - $out .= Add($row['zip2']); - $out .= Add(""); - $out .= Add(InsType($row['instype2'])); - $out .= Add($row['fname2'] . " " . $row['lname2']); - $out .= Add(ucfirst($row['relationship2'])); - $out .= Add($row['group2']); - $out .= Add($row['policy2']); + $out .= custom_labworks_Add($row['provider2']); + $out .= custom_labworks_Add($row['name2']); + $out .= custom_labworks_Add($row['street12']); + $out .= custom_labworks_Add($row['street22']); + $out .= custom_labworks_Add($row['city2']); + $out .= custom_labworks_Add($row['state2']); + $out .= custom_labworks_Add($row['zip2']); + $out .= custom_labworks_Add(""); + $out .= custom_labworks_Add(InsType($row['instype2'])); + $out .= custom_labworks_Add($row['fname2'] . " " . $row['lname2']); + $out .= custom_labworks_Add(ucfirst($row['relationship2'])); + $out .= custom_labworks_Add($row['group2']); + $out .= custom_labworks_Add($row['policy2']); // Primary Care Physician Section. // - $out .= Add($prow['id']); - $out .= Add($prow['lname']); - $out .= Add($prow['fname']); - $out .= Add(substr($prow['mname'], 0, 1)); - $out .= Add(""); // UPIN not available + $out .= custom_labworks_Add($prow['id']); + $out .= custom_labworks_Add($prow['lname']); + $out .= custom_labworks_Add($prow['fname']); + $out .= custom_labworks_Add(substr($prow['mname'], 0, 1)); + $out .= custom_labworks_Add(""); // UPIN not available // All done. $out .= "\rEND"; From de6eeaec8a4696925141e7a238e83c30296d70ef Mon Sep 17 00:00:00 2001 From: "Michael A. Smith" Date: Tue, 9 Sep 2025 14:28:22 -0400 Subject: [PATCH 006/112] fix(export_xml): avoid global Add function --- custom/export_xml.php | 164 +++++++++++++++++++++--------------------- 1 file changed, 82 insertions(+), 82 deletions(-) diff --git a/custom/export_xml.php b/custom/export_xml.php index d01917c6939c..e47ddf85739a 100644 --- a/custom/export_xml.php +++ b/custom/export_xml.php @@ -21,7 +21,7 @@ $indent = 0; // Add a string to output with some basic sanitizing. -function Add($tag, $text): void +function custom_xml_Add($tag, $text): void { global $out, $indent; $text = trim(str_replace(array("\r", "\n", "\t"), " ", ($text ?? ''))); @@ -84,39 +84,39 @@ function addInsurance($row, $seq): void { if ($row["name$seq"]) { OpenTag("insurance"); - Add("priority", $seq); - Add("group", $row["group$seq"]); - Add("policy", $row["policy$seq"]); - Add("provider", $row["provider$seq"]); - Add("name", $row["name$seq"]); - Add("street1", $row["street1$seq"]); - Add("street2", $row["street2$seq"]); - Add("city", $row["city$seq"]); - Add("state", $row["state$seq"]); - Add("zip", $row["zip$seq"]); - Add("country", $row["country$seq"]); - Add("type", $row["instype$seq"]); - Add("copay", $row["copay$seq"]); + custom_xml_Add("priority", $seq); + custom_xml_Add("group", $row["group$seq"]); + custom_xml_Add("policy", $row["policy$seq"]); + custom_xml_Add("provider", $row["provider$seq"]); + custom_xml_Add("name", $row["name$seq"]); + custom_xml_Add("street1", $row["street1$seq"]); + custom_xml_Add("street2", $row["street2$seq"]); + custom_xml_Add("city", $row["city$seq"]); + custom_xml_Add("state", $row["state$seq"]); + custom_xml_Add("zip", $row["zip$seq"]); + custom_xml_Add("country", $row["country$seq"]); + custom_xml_Add("type", $row["instype$seq"]); + custom_xml_Add("copay", $row["copay$seq"]); OpenTag("subscriber"); - Add("relationship", $row["relationship$seq"]); - Add("lname", $row["lname$seq"]); - Add("fname", $row["fname$seq"]); - Add("mname", $row["mname$seq"]); - Add("street", $row["sstreet$seq"]); - Add("city", $row["scity$seq"]); - Add("state", $row["sstate$seq"]); - Add("zip", $row["szip$seq"]); - Add("country", $row["scountry$seq"]); - Add("dob", $row["sdob$seq"]); - Add("ss", $row["sss$seq"]); - Add("phone", $row["sphone$seq"]); - Add("employer", $row["semployer$seq"]); - Add("sex", $row["ssex$seq"]); - Add("employer_street", $row["semployer_street$seq"]); - Add("employer_city", $row["semployer_city$seq"]); - Add("employer_state", $row["semployer_state$seq"]); - Add("employer_zip", $row["semployer_zip$seq"]); - Add("employer_country", $row["semployer_country$seq"]); + custom_xml_Add("relationship", $row["relationship$seq"]); + custom_xml_Add("lname", $row["lname$seq"]); + custom_xml_Add("fname", $row["fname$seq"]); + custom_xml_Add("mname", $row["mname$seq"]); + custom_xml_Add("street", $row["sstreet$seq"]); + custom_xml_Add("city", $row["scity$seq"]); + custom_xml_Add("state", $row["sstate$seq"]); + custom_xml_Add("zip", $row["szip$seq"]); + custom_xml_Add("country", $row["scountry$seq"]); + custom_xml_Add("dob", $row["sdob$seq"]); + custom_xml_Add("ss", $row["sss$seq"]); + custom_xml_Add("phone", $row["sphone$seq"]); + custom_xml_Add("employer", $row["semployer$seq"]); + custom_xml_Add("sex", $row["ssex$seq"]); + custom_xml_Add("employer_street", $row["semployer_street$seq"]); + custom_xml_Add("employer_city", $row["semployer_city$seq"]); + custom_xml_Add("employer_state", $row["semployer_state$seq"]); + custom_xml_Add("employer_zip", $row["semployer_zip$seq"]); + custom_xml_Add("employer_country", $row["semployer_country$seq"]); CloseTag("subscriber"); CloseTag("insurance"); } @@ -193,45 +193,45 @@ function addInsurance($row, $seq): void // Patient Section. // - Add("pid", $pid); - Add("pubpid", $row['pubpid']); - Add("lname", $row['lname']); - Add("fname", $row['fname']); - Add("mname", $row['mname']); - Add("title", $row['title']); - Add("ss", Digits($row['ss'])); - Add("dob", LWDate($row['DOB'])); - Add("sex", Sex($row['sex'])); - Add("street", $row['street']); - Add("city", $row['city']); - Add("state", $row['state']); - Add("zip", $row['postal_code']); - Add("country", $row['country_code']); - Add("phone_home", Digits($row['phone_home'])); - Add("phone_biz", Digits($row['phone_biz'])); - Add("phone_contact", Digits($row['phone_contact'])); - Add("phone_cell", Digits($row['phone_cell'])); - Add("occupation", $row['occupation']); - Add("status", $row['status']); - Add("contact_relationship", $row['contact_relationship']); - Add("referrer", $row['referrer']); - Add("referrerID", $row['referrerID']); - Add("email", $row['email']); - Add("language", $row['language']); - Add("ethnoracial", $row['ethnoracial']); - Add("interpreter", $row['interpretter']); - Add("migrantseasonal", $row['migrantseasonal']); - Add("family_size", $row['family_size']); - Add("monthly_income", $row['monthly_income']); - Add("homeless", $row['homeless']); - Add("financial_review", LWDate(substr($row['financial_review'], 0, 10))); - Add("genericname1", $row['genericname1']); - Add("genericval1", $row['genericval1']); - Add("genericname2", $row['genericname2']); - Add("genericval2", $row['genericval2']); - Add("billing_note", $row['billing_note']); - Add("hipaa_mail", $row['hipaa_mail']); - Add("hipaa_voice", $row['hipaa_voice']); + custom_xml_Add("pid", $pid); + custom_xml_Add("pubpid", $row['pubpid']); + custom_xml_Add("lname", $row['lname']); + custom_xml_Add("fname", $row['fname']); + custom_xml_Add("mname", $row['mname']); + custom_xml_Add("title", $row['title']); + custom_xml_Add("ss", Digits($row['ss'])); + custom_xml_Add("dob", LWDate($row['DOB'])); + custom_xml_Add("sex", Sex($row['sex'])); + custom_xml_Add("street", $row['street']); + custom_xml_Add("city", $row['city']); + custom_xml_Add("state", $row['state']); + custom_xml_Add("zip", $row['postal_code']); + custom_xml_Add("country", $row['country_code']); + custom_xml_Add("phone_home", Digits($row['phone_home'])); + custom_xml_Add("phone_biz", Digits($row['phone_biz'])); + custom_xml_Add("phone_contact", Digits($row['phone_contact'])); + custom_xml_Add("phone_cell", Digits($row['phone_cell'])); + custom_xml_Add("occupation", $row['occupation']); + custom_xml_Add("status", $row['status']); + custom_xml_Add("contact_relationship", $row['contact_relationship']); + custom_xml_Add("referrer", $row['referrer']); + custom_xml_Add("referrerID", $row['referrerID']); + custom_xml_Add("email", $row['email']); + custom_xml_Add("language", $row['language']); + custom_xml_Add("ethnoracial", $row['ethnoracial']); + custom_xml_Add("interpreter", $row['interpretter']); + custom_xml_Add("migrantseasonal", $row['migrantseasonal']); + custom_xml_Add("family_size", $row['family_size']); + custom_xml_Add("monthly_income", $row['monthly_income']); + custom_xml_Add("homeless", $row['homeless']); + custom_xml_Add("financial_review", LWDate(substr($row['financial_review'], 0, 10))); + custom_xml_Add("genericname1", $row['genericname1']); + custom_xml_Add("genericval1", $row['genericval1']); + custom_xml_Add("genericname2", $row['genericname2']); + custom_xml_Add("genericval2", $row['genericval2']); + custom_xml_Add("billing_note", $row['billing_note']); + custom_xml_Add("hipaa_mail", $row['hipaa_mail']); + custom_xml_Add("hipaa_voice", $row['hipaa_voice']); // Insurance Sections. // @@ -246,10 +246,10 @@ function addInsurance($row, $seq): void $query .= " AND id = ?"; $prow = sqlFetchArray(sqlStatement($query, array($row['providerID']))); OpenTag("pcp"); - Add("id", $prow['id']); - Add("lname", $prow['lname']); - Add("fname", $prow['fname']); - Add("mname", $prow['mname']); + custom_xml_Add("id", $prow['id']); + custom_xml_Add("lname", $prow['lname']); + custom_xml_Add("fname", $prow['fname']); + custom_xml_Add("mname", $prow['mname']); CloseTag("pcp"); } @@ -257,12 +257,12 @@ function addInsurance($row, $seq): void // if (!empty($rowed['id'])) { OpenTag("employer"); - Add("name", $rowed['name']); - Add("street", $rowed['street']); - Add("zip", $rowed['postal_code']); - Add("city", $rowed['city']); - Add("state", $rowed['state']); - Add("country", $rowed['country']); + custom_xml_Add("name", $rowed['name']); + custom_xml_Add("street", $rowed['street']); + custom_xml_Add("zip", $rowed['postal_code']); + custom_xml_Add("city", $rowed['city']); + custom_xml_Add("state", $rowed['state']); + custom_xml_Add("country", $rowed['country']); CloseTag("employer"); } From 5a831d8934a6b7253a773953840b4c1cf224017c Mon Sep 17 00:00:00 2001 From: "Michael A. Smith" Date: Tue, 9 Sep 2025 14:28:33 -0400 Subject: [PATCH 007/112] chore(phpstan): remove unused ignores --- phpstan.github.neon | 8 -------- 1 file changed, 8 deletions(-) diff --git a/phpstan.github.neon b/phpstan.github.neon index 64960d282c5e..9f40ad2e74bb 100644 --- a/phpstan.github.neon +++ b/phpstan.github.neon @@ -360,10 +360,6 @@ parameters: identifier: variable.undefined count: 7 path: custom/export_qrda_xml.php - - message: '#^Function Add invoked with 2 parameters, 1 required\.$#' - identifier: arguments.count - count: 81 - path: custom/export_xml.php - message: '#^Variable \$pid might not be defined\.$#' identifier: variable.undefined count: 4 @@ -11168,10 +11164,6 @@ parameters: identifier: empty.variable count: 1 path: interface/main/finder/patient_select.php - - message: '#^Function Add invoked with 2 parameters, 1 required\.$#' - identifier: arguments.count - count: 68 - path: interface/main/ippf_export.php - message: '#^Variable \$srcdir might not be defined\.$#' identifier: variable.undefined count: 1 From 779c4ebc4a02291f61adba85de45b37b2ad87a26 Mon Sep 17 00:00:00 2001 From: "Michael A. Smith" Date: Wed, 10 Sep 2025 09:55:01 -0400 Subject: [PATCH 008/112] test(Installer) (#8873) * fix(Installer): type annotations * test(Installer) * doc(Installer): docblocks * style(InstallerTest) * test(InstallerTest) * test(Installer): install_additional_users * chore(Installer): mysqli_fetch_array wrapper * test(Installer): on_care_coordination * test(Installer): get_initial_user_mfa_totp * chore(Installer): wrappers for unlink, glob * test(Installer): create_site_directory * chore(Installer): wrapper for `touch` * chore(Installer): wrapper for `fwrite` * test(Installer): write_configuration_file * chore(Installer): wrap GACL for mocking * test(Installer): install_gacl * chore(Installer): no coverage for escapeSql * chore(Installer): reorder mock wrappers at end * test(Installer): no auto-mock connect_to_database * test(Installer): mock die * test(Installer): test some of quick_install * test(Installer): execute_sql * test(Installer): connect_to_database * test(Installer): theme methods * refactor(InstallerTest): rector * style(InstallerTest): whitespace --- library/classes/Installer.class.php | 1093 +++-- .../library/classes/InstallerTest.php | 3742 +++++++++++++++++ 2 files changed, 4591 insertions(+), 244 deletions(-) create mode 100644 tests/Tests/Isolated/library/classes/InstallerTest.php diff --git a/library/classes/Installer.class.php b/library/classes/Installer.class.php index 0a36bfa75d44..212f9b0a5ab2 100644 --- a/library/classes/Installer.class.php +++ b/library/classes/Installer.class.php @@ -1,7 +1,6 @@ dbh = false; } - public function login_is_valid() + /** + * Validate if the database login is valid. + * + * @return bool True if login is valid, false otherwise + */ + public function login_is_valid(): bool { - if (($this->login == '') || (! isset($this->login))) { + if ($this->login === '') { $this->error_message = "login is invalid: '$this->login'"; return false; } @@ -118,10 +127,18 @@ public function login_is_valid() return true; } - public function char_is_valid($input_text) + /** + * Validate if input text contains only safe characters. + * + * Prevents PHP injection by checking for dangerous characters. + * + * @param string $input_text Text to validate + * @return bool True if text is safe, false otherwise + */ + public function char_is_valid(string $input_text): bool { // to prevent php injection - trim($input_text); + $input_text = trim($input_text); if ($input_text == '') { return false; } @@ -133,7 +150,13 @@ public function char_is_valid($input_text) return true; } - public function databaseNameIsValid($name) + /** + * Validate if database name contains only allowed characters. + * + * @param string $name Database name to validate + * @return bool True if name is valid, false otherwise + */ + public function databaseNameIsValid(string $name): bool { if (preg_match('/[^A-Za-z0-9_-]/', $name)) { return false; @@ -141,7 +164,13 @@ public function databaseNameIsValid($name) return true; } - public function collateNameIsValid($name) + /** + * Validate if collation name contains only allowed characters. + * + * @param string $name Collation name to validate + * @return bool True if name is valid, false otherwise + */ + public function collateNameIsValid(string $name): bool { if (preg_match('/[^A-Za-z0-9_-]/', $name)) { return false; @@ -149,7 +178,12 @@ public function collateNameIsValid($name) return true; } - public function iuser_is_valid() + /** + * Validate if the initial user name is valid. + * + * @return bool True if initial user is valid, false otherwise + */ + public function iuser_is_valid(): bool { if (strpos($this->iuser, " ")) { $this->error_message = "Initial user is invalid: '$this->iuser'"; @@ -159,9 +193,14 @@ public function iuser_is_valid() return true; } - public function iuname_is_valid() + /** + * Validate if the initial user last name is valid. + * + * @return bool True if initial user last name is valid, false otherwise + */ + public function iuname_is_valid(): bool { - if ($this->iuname == "" || !isset($this->iuname)) { + if ($this->iuname === '') { $this->error_message = "Initial user last name is invalid: '$this->iuname'"; return false; } @@ -169,9 +208,14 @@ public function iuname_is_valid() return true; } - public function password_is_valid() + /** + * Validate if the database password is valid. + * + * @return bool True if password is valid, false otherwise + */ + public function password_is_valid(): bool { - if ($this->pass == "" || !isset($this->pass)) { + if ($this->pass === '') { $this->error_message = "The password for the new database account is invalid: '$this->pass'"; return false; } @@ -179,9 +223,14 @@ public function password_is_valid() return true; } - public function user_password_is_valid() + /** + * Validate if the initial user password is valid. + * + * @return bool True if user password is valid, false otherwise + */ + public function user_password_is_valid(): bool { - if ($this->iuserpass == "" || !isset($this->iuserpass)) { + if ($this->iuserpass === '') { $this->error_message = "The password for the user is invalid: '$this->iuserpass'"; return false; } @@ -189,9 +238,14 @@ public function user_password_is_valid() return true; } - - - public function root_database_connection() + /** + * Establish a database connection using root credentials. + * + * Connects to the database server using root privileges and sets strict SQL mode. + * + * @return bool True if connection successful, false otherwise + */ + public function root_database_connection(): bool { $this->dbh = $this->connect_to_database($this->server, $this->root, $this->rootpass, $this->port); if ($this->dbh) { @@ -207,7 +261,15 @@ public function root_database_connection() } } - public function user_database_connection() + /** + * Establish a database connection using user credentials. + * + * Connects to the database server using the configured user account, + * sets strict SQL mode, collation, and selects the target database. + * + * @return bool True if connection successful, false otherwise + */ + public function user_database_connection(): bool { $this->dbh = $this->connect_to_database($this->server, $this->login, $this->pass, $this->port, $this->dbname); if (! $this->dbh) { @@ -225,7 +287,7 @@ public function user_database_connection() return false; } - if (! mysqli_select_db($this->dbh, $this->dbname)) { + if (! $this->mysqliSelectDb($this->dbh, $this->dbname)) { $this->error_message = "unable to select database: '$this->dbname'"; return false; } @@ -233,7 +295,15 @@ public function user_database_connection() return true; } - public function create_database() + /** + * Create the target database with UTF8MB4 character set. + * + * Creates the database using the configured name and collation, + * defaulting to utf8mb4_general_ci if not specified. + * + * @return bool True if database creation successful, false otherwise + */ + public function create_database(): bool { $sql = "create database " . $this->escapeDatabaseName($this->dbname); if (empty($this->collate) || ($this->collate == 'utf8_general_ci')) { @@ -245,69 +315,96 @@ public function create_database() return $this->execute_sql($sql); } - public function drop_database() + /** + * Drop the target database if it exists. + * + * @return bool True if database drop successful, false otherwise + */ + public function drop_database(): bool { $sql = "drop database if exists " . $this->escapeDatabaseName($this->dbname); return $this->execute_sql($sql); } - public function create_database_user() + /** + * Create or update the database user account. + * + * Checks if the user exists in mysql.user (or mysql.global_priv for MariaDB 10.4+), + * creates the user if it doesn't exist, or updates the password if it does. + * Supports X509 and SSL connection requirements based on environment variables. + * + * @return mysqli_result|bool Query result or false on error + */ + public function create_database_user(): mysqli_result|bool { + $escapedLogin = $this->escapeSql($this->login); + $escapedHost = $this->escapeSql($this->loginhost); + $escapedPass = $this->escapeSql($this->pass); + // First, check for database user in the mysql.user table (this works for all except mariadb 10.4+) - $checkUser = $this->execute_sql("SELECT user FROM mysql.user WHERE user = '" . $this->escapeSql($this->login) . "' AND host = '" . $this->escapeSql($this->loginhost) . "'", false); + $checkUser = $this->execute_sql("SELECT user FROM mysql.user WHERE user = '{$escapedLogin}' AND host = '{$escapedHost}'", false); if ($checkUser === false) { // Above caused error, so is MariaDB 10.4+, and need to do below query instead in the mysql.global_priv table - $checkUser = $this->execute_sql("SELECT user FROM mysql.global_priv WHERE user = '" . $this->escapeSql($this->login) . "' AND host = '" . $this->escapeSql($this->loginhost) . "'"); + $checkUser = $this->execute_sql("SELECT user FROM mysql.global_priv WHERE user = '{$escapedLogin}' AND host = '{$escapedHost}'"); } if ($checkUser === false) { // there was an error in the check database user query, so return false return false; - } elseif ($checkUser->num_rows > 0) { + } elseif ($this->mysqliNumRows($checkUser) > 0) { // the mysql user already exists, so do not need to create the user, but need to set the password // Note need to try two different methods, first is for newer mysql versions and second is for older mysql versions (if the first method fails) - $returnSql = $this->execute_sql("ALTER USER '" . $this->escapeSql($this->login) . "'@'" . $this->escapeSql($this->loginhost) . "' IDENTIFIED BY '" . $this->escapeSql($this->pass) . "'", false); + $returnSql = $this->execute_sql("ALTER USER '{$escapedLogin}'@'{$escapedHost}' IDENTIFIED BY '{$escapedPass}'", false); if ($returnSql === false) { error_log("Using older mysql version method to set password for the mysql user"); - $returnSql = $this->execute_sql("SET PASSWORD FOR '" . $this->escapeSql($this->login) . "'@'" . $this->escapeSql($this->loginhost) . "' = PASSWORD('" . $this->escapeSql($this->pass) . "')"); + $returnSql = $this->execute_sql("SET PASSWORD FOR '{$escapedLogin}'@'{$escapedHost}' = PASSWORD('{$escapedPass}')"); } return $returnSql; } else { // the mysql user does not yet exist, so create the user if (getenv('FORCE_DATABASE_X509_CONNECT', true) == 1) { // this use case is to allow enforcement of x509 database connection use in applicable docker and kubernetes auto installations - return $this->execute_sql("CREATE USER '" . $this->escapeSql($this->login) . "'@'" . $this->escapeSql($this->loginhost) . "' IDENTIFIED BY '" . $this->escapeSql($this->pass) . "' REQUIRE X509"); + return $this->execute_sql("CREATE USER '{$escapedLogin}'@'{$escapedHost}' IDENTIFIED BY '{$escapedPass}' REQUIRE X509"); } elseif (getenv('FORCE_DATABASE_SSL_CONNECT', true) == 1) { // this use case is to allow enforcement of ssl database connection use in applicable docker and kubernetes auto installations - return $this->execute_sql("CREATE USER '" . $this->escapeSql($this->login) . "'@'" . $this->escapeSql($this->loginhost) . "' IDENTIFIED BY '" . $this->escapeSql($this->pass) . "' REQUIRE SSL"); + return $this->execute_sql("CREATE USER '{$escapedLogin}'@'{$escapedHost}' IDENTIFIED BY '{$escapedPass}' REQUIRE SSL"); } else { - return $this->execute_sql("CREATE USER '" . $this->escapeSql($this->login) . "'@'" . $this->escapeSql($this->loginhost) . "' IDENTIFIED BY '" . $this->escapeSql($this->pass) . "'"); + return $this->execute_sql("CREATE USER '{$escapedLogin}'@'{$escapedHost}' IDENTIFIED BY '{$escapedPass}'"); } } } - public function grant_privileges() + /** + * Grant all privileges on the database to the user account. + * + * @return bool True if privileges granted successfully, false otherwise + */ + public function grant_privileges(): bool { return $this->execute_sql("GRANT ALL PRIVILEGES ON " . $this->escapeDatabaseName($this->dbname) . ".* TO '" . $this->escapeSql($this->login) . "'@'" . $this->escapeSql($this->loginhost) . "'"); } - public function disconnect() - { - return mysqli_close($this->dbh); - } - /** * This method creates any dumpfiles necessary. * This is actually only done if we're cloning an existing site * and we need to dump their database into a file. - * @return bool indicating success + * + * @return string name of the backup file */ - public function create_dumpfiles() + public function create_dumpfiles(): string { return $this->dumpSourceDatabase(); } - public function load_dumpfiles() + /** + * Load all configured database dump files. + * + * Iterates through the list of dump files and loads each one, + * accumulating results and returning combined output. + * + * @return string|false Combined results from all loaded files, or false on error + */ + public function load_dumpfiles(): string|false { $sql_results = ''; // information string which is returned foreach ($this->dumpfiles as $filename => $title) { @@ -323,11 +420,22 @@ public function load_dumpfiles() return $sql_results; } - public function load_file($filename, $title) + /** + * Load and execute SQL commands from a database dump file. + * + * Opens the specified file, reads it line by line, and executes + * SQL statements. Uses transactions for improved performance with InnoDB. + * Ignores comment lines starting with -- or #. + * + * @param string $filename Path to the SQL dump file + * @param string $title Descriptive title for the operation + * @return string|false Success message or false on error + */ + public function load_file(string $filename, string $title): string|false { $sql_results = ''; // information string which is returned $sql_results .= "Creating $title tables...\n"; - $fd = fopen($filename, 'r'); + $fd = $this->openFile($filename, 'r'); if ($fd == false) { $this->error_message = "ERROR. Could not open dumpfile '$filename'.\n"; return false; @@ -345,30 +453,22 @@ public function load_file($filename, $title) return false; } - while (!feof($fd)) { - $line = fgets($fd, 1024); + while (!$this->atEndOfFile($fd)) { + $line = $this->getLine($fd, 1024); $line = rtrim($line); - if (substr($line, 0, 2) == "--") { // Kill comments - continue; - } - - if (substr($line, 0, 1) == "#") { // Kill comments - continue; - } - - if ($line == "") { - continue; + if ($line === "" || substr($line, 0, 2) === "--" || substr($line, 0, 1) === "#") { + continue; } $query .= $line; // Check for full query $chr = substr($query, strlen($query) - 1, 1); if ($chr == ";") { // valid query, execute - $query = rtrim($query, ";"); + $query = rtrim($query, ";"); if (! $this->execute_sql($query)) { return false; } - $query = ""; + $query = ""; } } @@ -382,11 +482,19 @@ public function load_file($filename, $title) } $sql_results .= "OK.
\n"; - fclose($fd); + $this->closeFile($fd); return $sql_results; } - public function add_version_info() + /** + * Add version information to the database. + * + * Loads version constants from version.php and updates the version table + * with current OpenEMR version information. + * + * @return bool True if version info added successfully, false otherwise + */ + public function add_version_info(): bool { include __DIR__ . "/../../version.php"; /** @@ -421,44 +529,57 @@ public function add_version_info() return true; } $this->error_message = "ERROR. Unable insert version information into database\n" . - "

" . mysqli_error($this->dbh) . " (#" . mysqli_errno($this->dbh) . ")\n"; + "

" . $this->mysqliError($this->dbh) . " (#" . $this->mysqliErrno($this->dbh) . ")\n"; return false; } - public function add_initial_user() + /** + * Add the initial administrator user to the database. + * + * Creates the initial user group, user account, secure password hash, + * and optionally sets up 2FA if enabled during installation. + * + * @return bool True if initial user added successfully, false otherwise + */ + public function add_initial_user(): bool { - if ($this->execute_sql("INSERT INTO `groups` (id, name, user) VALUES (1,'" . $this->escapeSql($this->igroup) . "','" . $this->escapeSql($this->iuser) . "')") == false) { + $escapedGroup = $this->escapeSql($this->igroup); + $escapedUser = $this->escapeSql($this->iuser); + $escapedFirstName = $this->escapeSql($this->iufname); + $escapedLastName = $this->escapeSql($this->iuname); + if ($this->execute_sql("INSERT INTO `groups` (id, name, user) VALUES (1,'{$escapedGroup}', '{$escapedUser}')") == false) { $this->error_message = "ERROR. Unable to add initial user group\n" . - "

" . mysqli_error($this->dbh) . " (#" . mysqli_errno($this->dbh) . ")\n"; + "

" . $this->mysqliError($this->dbh) . " (#" . $this->mysqliErrno($this->dbh) . ")\n"; return false; } - if ($this->execute_sql("INSERT INTO users (id, username, password, authorized, lname, fname, facility_id, calendar, cal_ui) VALUES (1,'" . $this->escapeSql($this->iuser) . "','NoLongerUsed',1,'" . $this->escapeSql($this->iuname) . "','" . $this->escapeSql($this->iufname) . "',3,1,3)") == false) { + if ($this->execute_sql("INSERT INTO users (id, username, password, authorized, lname, fname, facility_id, calendar, cal_ui) VALUES (1,'{$escapedUser}','NoLongerUsed',1,'{$escapedLastName}','{$escapedFirstName}',3,1,3)") == false) { $this->error_message = "ERROR. Unable to add initial user\n" . - "

" . mysqli_error($this->dbh) . " (#" . mysqli_errno($this->dbh) . ")\n"; + "

" . $this->mysqliError($this->dbh) . " (#" . $this->mysqliErrno($this->dbh) . ")\n"; return false; } $hash = password_hash($this->iuserpass, PASSWORD_DEFAULT); + $escapedHash = $this->escapeSql($hash); if (empty($hash)) { // Something is seriously wrong error_log('OpenEMR Error : OpenEMR is not working because unable to create a hash.'); - die("OpenEMR Error : OpenEMR is not working because unable to create a hash."); + $this->die("OpenEMR Error : OpenEMR is not working because unable to create a hash."); } - if ($this->execute_sql("INSERT INTO users_secure (id, username, password, last_update_password) VALUES (1,'" . $this->escapeSql($this->iuser) . "','" . $this->escapeSql($hash) . "',NOW())") == false) { + if ($this->execute_sql("INSERT INTO users_secure (id, username, password, last_update_password) VALUES (1,'{$escapedUser}','{$escapedHash}',NOW())") == false) { $this->error_message = "ERROR. Unable to add initial user login credentials\n" . - "

" . mysqli_error($this->dbh) . " (#" . mysqli_errno($this->dbh) . ")\n"; + "

" . $this->mysqliError($this->dbh) . " (#" . $this->mysqliErrno($this->dbh) . ")\n"; return false; } // Create new 2fa if enabled - if (($this->i2faEnable) && (!empty($this->i2faSecret)) && (class_exists('Totp')) && (class_exists('OpenEMR\Common\Crypto\CryptoGen'))) { + if (($this->i2faEnable) && (!empty($this->i2faSecret)) && $this->totpClassExists() && $this->cryptoGenClassExists()) { // Encrypt the new secret with the hashed password - $cryptoGen = new OpenEMR\Common\Crypto\CryptoGen(); - $secret = $cryptoGen->encryptStandard($this->i2faSecret, $hash); - if ($this->execute_sql("INSERT INTO login_mfa_registrations (user_id, name, method, var1, var2) VALUES (1, 'App Based 2FA', 'TOTP', '" . $this->escapeSql($secret) . "', '')") == false) { + $secret = $this->encryptTotpSecret($this->i2faSecret, $hash); + $escapedSecret = $this->escapeSql($secret); + if ($this->execute_sql("INSERT INTO login_mfa_registrations (user_id, name, method, var1, var2) VALUES (1, 'App Based 2FA', 'TOTP', '{$escapedSecret}', '')") == false) { $this->error_message = "ERROR. Unable to add initial user's 2FA credentials\n" . - "

" . mysqli_error($this->dbh) . " (#" . mysqli_errno($this->dbh) . ")\n"; + "

" . $this->mysqliError($this->dbh) . " (#" . $this->mysqliErrno($this->dbh) . ")\n"; return false; } } @@ -468,9 +589,10 @@ public function add_initial_user() /** * Handle the additional users now that our gacl's have finished installing. + * * @return bool */ - public function install_additional_users() + public function install_additional_users(): bool { // Add the official openemr users (services) if ($this->load_file($this->additional_users, "Additional Official Users") == false) { @@ -479,10 +601,18 @@ public function install_additional_users() return true; } - public function on_care_coordination() + /** + * Configure Care Coordination module ACL permissions. + * + * Sets up module access control by linking the Carecoordination module + * to the admin group with appropriate permissions. + * + * @return bool True if configuration successful, false otherwise + */ + public function on_care_coordination(): bool { $resource = $this->execute_sql("SELECT `mod_id` FROM `modules` WHERE `mod_name` = 'Carecoordination' LIMIT 1"); - $resource_array = mysqli_fetch_array($resource, MYSQLI_ASSOC); + $resource_array = $this->mysqliFetchArray($resource, MYSQLI_ASSOC); $modId = $resource_array['mod_id']; if (empty($modId)) { $this->error_message = "ERROR configuring Care Coordination module. Unable to get mod_id for Carecoordination module\n"; @@ -490,7 +620,7 @@ public function on_care_coordination() } $resource = $this->execute_sql("SELECT `section_id` FROM `module_acl_sections` WHERE `section_identifier` = 'carecoordination' LIMIT 1"); - $resource_array = mysqli_fetch_array($resource, MYSQLI_ASSOC); + $resource_array = $this->mysqliFetchArray($resource, MYSQLI_ASSOC); $sectionId = $resource_array['section_id']; if (empty($sectionId)) { $this->error_message = "ERROR configuring Care Coordination module. Unable to get section_id for carecoordination module section\n"; @@ -498,7 +628,7 @@ public function on_care_coordination() } $resource = $this->execute_sql("SELECT `id` FROM `gacl_aro_groups` WHERE `value` = 'admin' LIMIT 1"); - $resource_array = mysqli_fetch_array($resource, MYSQLI_ASSOC); + $resource_array = $this->mysqliFetchArray($resource, MYSQLI_ASSOC); $groupId = $resource_array['id']; if (empty($groupId)) { $this->error_message = "ERROR configuring Care Coordination module. Unable to get id for gacl_aro_groups admin section\n"; @@ -515,25 +645,26 @@ public function on_care_coordination() /** * Generates the initial user's 2FA QR Code - * @return bool|string|void + * + * @return Totp|false */ - public function get_initial_user_mfa_totp() + public function get_initial_user_mfa_totp(): Totp|false { - if (($this->i2faEnable) && (!empty($this->i2faSecret)) && (class_exists('Totp'))) { - $adminTotp = new Totp($this->i2faSecret, $this->iuser); - return $adminTotp; + if (($this->i2faEnable) && (!empty($this->i2faSecret)) && $this->totpClassExists()) { + return $this->createTotpInstance($this->i2faSecret, $this->iuser); } return false; } /** * Create site directory if it is missing. + * * @global string $GLOBALS['OE_SITE_DIR'] contains the name of the site directory to create - * @return name of the site directory or False + * @return bool true if the site directory was created or false if it already exists */ - public function create_site_directory() + public function create_site_directory(): bool { - if (!file_exists($GLOBALS['OE_SITE_DIR'])) { + if (!$this->fileExists($GLOBALS['OE_SITE_DIR'])) { $source_directory = $GLOBALS['OE_SITES_BASE'] . "/" . $this->source_site_id; $destination_directory = $GLOBALS['OE_SITE_DIR']; if (! $this->recurse_copy($source_directory, $destination_directory)) { @@ -542,20 +673,31 @@ public function create_site_directory() } // the new site will create it's own keys so okay to delete these copied from the source site if (!$this->clone_database) { - array_map('unlink', glob($destination_directory . "/documents/logs_and_misc/methods/*")); + $files = $this->globPattern($destination_directory . "/documents/logs_and_misc/methods/*"); + if ($files !== false) { + array_map([$this, 'unlinkFile'], $files); + } } } return true; } - public function write_configuration_file() + /** + * Write the database configuration file (sqlconf.php). + * + * Creates the site directory if needed and writes the database + * connection configuration to the sqlconf.php file. + * + * @return bool True if configuration written successfully, false otherwise + */ + public function write_configuration_file(): bool { - if (!file_exists($GLOBALS['OE_SITE_DIR'])) { + if (!$this->fileExists($GLOBALS['OE_SITE_DIR'])) { $this->create_site_directory(); } - @touch($this->conffile); // php bug - $fd = @fopen($this->conffile, 'w'); + @$this->touchFile($this->conffile); // php bug + $fd = @$this->openFile($this->conffile, 'w'); if (! $fd) { $this->error_message = 'unable to open configuration file for writing: ' . $this->conffile; return false; @@ -569,15 +711,15 @@ public function write_configuration_file() $it_died = 0; //fmg: variable keeps running track of any errors - fwrite($fd, $string) or $it_died++; - fwrite($fd, "global \$disable_utf8_flag;\n") or $it_died++; - fwrite($fd, "\$disable_utf8_flag = false;\n\n") or $it_died++; - fwrite($fd, "\$host\t= '$this->server';\n") or $it_died++; - fwrite($fd, "\$port\t= '$this->port';\n") or $it_died++; - fwrite($fd, "\$login\t= '$this->login';\n") or $it_died++; - fwrite($fd, "\$pass\t= '$this->pass';\n") or $it_died++; - fwrite($fd, "\$dbase\t= '$this->dbname';\n") or $it_died++; - fwrite($fd, "\$db_encoding\t= 'utf8mb4';\n") or $it_died++; + $this->writeToFile($fd, $string) or $it_died++; + $this->writeToFile($fd, "global \$disable_utf8_flag;\n") or $it_died++; + $this->writeToFile($fd, "\$disable_utf8_flag = false;\n\n") or $it_died++; + $this->writeToFile($fd, "\$host\t= '$this->server';\n") or $it_died++; + $this->writeToFile($fd, "\$port\t= '$this->port';\n") or $it_died++; + $this->writeToFile($fd, "\$login\t= '$this->login';\n") or $it_died++; + $this->writeToFile($fd, "\$pass\t= '$this->pass';\n") or $it_died++; + $this->writeToFile($fd, "\$dbase\t= '$this->dbname';\n") or $it_died++; + $this->writeToFile($fd, "\$db_encoding\t= 'utf8mb4';\n") or $it_died++; $string = ' $sqlconf = array(); @@ -600,8 +742,8 @@ public function write_configuration_file() ?> '; - fwrite($fd, $string) or $it_died++; - fclose($fd) or $it_died++; + $this->writeToFile($fd, $string) or $it_died++; + $this->closeFile($fd) or $it_died++; //it's rather irresponsible to not report errors when writing this file. if ($it_died != 0) { @@ -610,14 +752,24 @@ public function write_configuration_file() } // Tell PHP that its cached bytecode version of sqlconf.php is no longer usable. + // @codeCoverageIgnoreStart if (function_exists('opcache_invalidate')) { opcache_invalidate($this->conffile, true); } + // @codeCoverageIgnoreEnd return true; } - public function insert_globals() + /** + * Insert global configuration settings into the database. + * + * Loads the global settings metadata and inserts default values + * into the globals table for system configuration. + * + * @return true Always returns true + */ + public function insert_globals(): true { $GLOBALS['temp_skip_translations'] = true; $skipGlobalEvent = true; // use in globals.inc.php script to skip event stuff @@ -628,7 +780,7 @@ public function insert_globals() list($fldname, $fldtype, $flddef, $flddesc) = $fldarr; if (is_array($fldtype) || substr($fldtype, 0, 2) !== 'm_') { $res = $this->execute_sql("SELECT count(*) AS count FROM globals WHERE gl_name = '" . $this->escapeSql($fldid) . "'"); - $row = mysqli_fetch_array($res, MYSQLI_ASSOC); + $row = $this->mysqliFetchArray($res, MYSQLI_ASSOC); if (empty($row['count'])) { $this->execute_sql("INSERT INTO globals ( gl_name, gl_index, gl_value ) " . "VALUES ( '" . $this->escapeSql($fldid) . "', '0', '" . $this->escapeSql($flddef) . "' )"); @@ -640,13 +792,20 @@ public function insert_globals() return true; } - public function install_gacl() - { - $gacl = new GaclApi(); + /** + * Install the Generic Access Control List (GACL) system. + * + * Creates all access control objects (ACOs), sections, and groups + * needed for OpenEMR's role-based access control system. + * + * @return bool True if GACL installation successful, false otherwise + */ + public function install_gacl(): bool + { + $gacl = $this->newGaclApi(); // Create the ACO sections. Every ACO must have a section. - // if ($gacl->add_object_section('Accounting', 'acct', 10, 0, 'ACO') === false) { $this->error_message = "ERROR, Unable to create the access controls for OpenEMR."; return false; @@ -678,7 +837,6 @@ public function install_gacl() // xl('Inventory') // Create Accounting ACOs. - // $gacl->add_object('acct', 'Billing (write optional)', 'bill', 10, 0, 'ACO'); // xl('Billing (write optional)') $gacl->add_object('acct', 'Price Discounting', 'disc', 10, 0, 'ACO'); @@ -691,7 +849,6 @@ public function install_gacl() // xl('Financial Reporting - anything') // Create Administration ACOs. - // $gacl->add_object('admin', 'Superuser', 'super', 10, 0, 'ACO'); // xl('Superuser') $gacl->add_object('admin', 'Calendar Settings', 'calendar', 10, 0, 'ACO'); @@ -723,7 +880,6 @@ public function install_gacl() // Create ACOs for encounters. - // $gacl->add_object('encounters', 'Authorize - my encounters', 'auth', 10, 0, 'ACO'); // xl('Authorize - my encounters') $gacl->add_object('encounters', 'Authorize - any encounters', 'auth_a', 10, 0, 'ACO'); @@ -738,11 +894,10 @@ public function install_gacl() // xl('Notes - any encounters (write,addonly optional)') $gacl->add_object('encounters', 'Fix encounter dates - any encounters', 'date_a', 10, 0, 'ACO'); // xl('Fix encounter dates - any encounters') - $gacl->add_object('encounters', 'Less-private information (write,addonly optional)', 'relaxed', 10, 0, 'ACO'); - // xl('Less-private information (write,addonly optional)') + $gacl->add_object('encounters', 'Less-protected information (write,addonly optional)', 'relaxed', 10, 0, 'ACO'); + // xl('Less-protected information (write,addonly optional)') // Create ACOs for lists. - // $gacl->add_object('lists', 'Default List (write,addonly optional)', 'default', 10, 0, 'ACO'); // xl('Default List (write,addonly optional)') $gacl->add_object('lists', 'State List (write,addonly optional)', 'state', 10, 0, 'ACO'); @@ -755,17 +910,14 @@ public function install_gacl() // xl('Ethnicity-Race List (write,addonly optional)') // Create ACOs for patientportal. - // $gacl->add_object('patientportal', 'Patient Portal', 'portal', 10, 0, 'ACO'); // xl('Patient Portal') // Create ACOs for modules. - // $gacl->add_object('menus', 'Modules', 'modle', 10, 0, 'ACO'); // xl('Modules') // Create ACOs for patients. - // $gacl->add_object('patients', 'Appointments (write,wsome optional)', 'appt', 10, 0, 'ACO'); // xl('Appointments (write,wsome optional)') $gacl->add_object('patients', 'Demographics (write,addonly optional)', 'demo', 10, 0, 'ACO'); @@ -810,24 +962,20 @@ public function install_gacl() // xl('Send message from the permanent group therapist to the personal therapist') // Create ACOs for sensitivities. - // $gacl->add_object('sensitivities', 'Normal', 'normal', 10, 0, 'ACO'); // xl('Normal') $gacl->add_object('sensitivities', 'High', 'high', 20, 0, 'ACO'); // xl('High') // Create ACO for placeholder. - // $gacl->add_object('placeholder', 'Placeholder (Maintains empty ACLs)', 'filler', 10, 0, 'ACO'); // xl('Placeholder (Maintains empty ACLs)') // Create ACO for nationnotes. - // $gacl->add_object('nationnotes', 'Nation Notes Configure', 'nn_configure', 10, 0, 'ACO'); // xl('Nation Notes Configure') // Create ACOs for Inventory. - // $gacl->add_object('inventory', 'Lots', 'lots', 10, 0, 'ACO'); // xl('Lots') $gacl->add_object('inventory', 'Sales', 'sales', 20, 0, 'ACO'); @@ -846,7 +994,6 @@ public function install_gacl() // xl('Reporting') // Create ARO groups. - // $users = $gacl->add_group('users', 'OpenEMR Users', 0, 'ARO'); // xl('OpenEMR Users') $admin = $gacl->add_group('admin', 'Administrators', $users, 'ARO'); @@ -864,7 +1011,6 @@ public function install_gacl() // Create a Users section for the AROs (humans). - // $gacl->add_object_section('Users', 'users', 10, 0, 'ARO'); // xl('Users') @@ -873,19 +1019,13 @@ public function install_gacl() // If this script is being used by OpenEMR's setup, then will // incorporate the installation values. Otherwise will // hardcode the 'admin' user. - if (isset($this->iuser)) { - $gacl->add_object('users', $this->iuname, $this->iuser, 10, 0, 'ARO'); - $gacl->add_group_object($admin, 'users', $this->iuser, 'ARO'); - } else { - $gacl->add_object('users', 'Administrator', 'admin', 10, 0, 'ARO'); - $gacl->add_group_object($admin, 'users', 'admin', 'ARO'); - } + $gacl->add_object('users', $this->iuname, $this->iuser, 10, 0, 'ARO'); + $gacl->add_group_object($admin, 'users', $this->iuser, 'ARO'); // Declare return terms for language translations // xl('write') xl('wsome') xl('addonly') xl('view') // Set permissions for administrators. - // $gacl->add_acl( array( 'acct' => array('bill', 'disc', 'eob', 'rep', 'rep_a'), @@ -912,7 +1052,6 @@ public function install_gacl() // xl('Administrators can do anything') // Set permissions for physicians. - // $gacl->add_acl( array( 'patients' => array('pat_rep') @@ -977,7 +1116,6 @@ public function install_gacl() // xl('Things that physicians can read and modify') // Set permissions for clinicians. - // $gacl->add_acl( array( 'patients' => array('pat_rep') @@ -1041,7 +1179,6 @@ public function install_gacl() // xl('Things that clinicians can read and modify') // Set permissions for front office staff. - // $gacl->add_acl( array( 'patients' => array('alert') @@ -1101,7 +1238,6 @@ public function install_gacl() // xl('Things that front office can read and modify') // Set permissions for back office staff. - // $gacl->add_acl( array( 'patients' => array('alert') @@ -1163,7 +1299,6 @@ public function install_gacl() // xl('Things that back office can read and modify') // Set permissions for Emergency Login. - // $gacl->add_acl( array( 'acct' => array('bill', 'disc', 'eob', 'rep', 'rep_a'), @@ -1192,7 +1327,16 @@ public function install_gacl() return true; } - public function quick_install() + /** + * Perform a complete OpenEMR installation process. + * + * Orchestrates the entire installation by validating settings, + * creating databases and users, loading SQL files, configuring + * access controls, and setting up the initial system state. + * + * @return bool True if installation completed successfully, false otherwise + */ + public function quick_install(): bool { // Validation of OpenEMR user settings // (applicable if not cloning from another database) @@ -1320,30 +1464,51 @@ public function quick_install() return true; } - private function escapeSql($sql) - { - return mysqli_real_escape_string($this->dbh, $sql); - } - - private function escapeDatabaseName($name) + /** + * Validate and escape database name. + * + * Ensures database name contains only safe characters. + * + * @param string $name Database name to validate + * @return string Validated database name + * @throws void Dies if invalid characters found + */ + protected function escapeDatabaseName(string $name): string { if (preg_match('/[^A-Za-z0-9_-]/', $name)) { - error_log("Illegal character(s) in database name"); - die("Illegal character(s) in database name"); + $this->die("Illegal character(s) in database name"); } return $name; } - private function escapeCollateName($name) + /** + * Validate and escape collation name. + * + * Ensures collation name contains only safe characters. + * + * @param string $name Collation name to validate + * @return string Validated collation name + * @throws void Dies if invalid characters found + */ + protected function escapeCollateName(string $name): string { if (preg_match('/[^A-Za-z0-9_-]/', $name)) { - error_log("Illegal character(s) in collation name"); - die("Illegal character(s) in collation name"); + $this->die("Illegal character(s) in collation name"); } return $name; } - private function execute_sql($sql, $showError = true) + /** + * Execute SQL query with error handling. + * + * Executes a SQL query against the database connection, + * with optional error reporting and logging. + * + * @param string $sql SQL query to execute + * @param bool $showError Whether to log/display errors + * @return mysqli_result|bool Query result or false on error + */ + protected function execute_sql(string $sql, bool $showError = true): mysqli_result|bool { $this->error_message = ''; if (! $this->dbh) { @@ -1351,12 +1516,12 @@ private function execute_sql($sql, $showError = true) } try { - $results = mysqli_query($this->dbh, $sql); + $results = $this->mysqliQuery($this->dbh, $sql); if ($results) { return $results; } else { if ($showError) { - $error_mes = mysqli_error($this->dbh); + $error_mes = $this->mysqliError($this->dbh); $this->error_message = "unable to execute SQL: '$sql' due to: " . $error_mes; error_log("ERROR IN OPENEMR INSTALL: Unable to execute SQL: " . htmlspecialchars($sql, ENT_QUOTES) . " due to: " . htmlspecialchars($error_mes, ENT_QUOTES)); } @@ -1372,19 +1537,19 @@ private function execute_sql($sql, $showError = true) } } - private function connect_to_database($server, $user, $password, $port, $dbname = '') + protected function connect_to_database(string $server, string $user, string $password, int|string $port, string $dbname = ''): mysqli|false { $pathToCerts = __DIR__ . "/../../sites/" . $this->site . "/documents/certificates/"; $mysqlSsl = false; - $mysqli = mysqli_init(); - if (defined('MYSQLI_CLIENT_SSL') && file_exists($pathToCerts . "mysql-ca")) { + $mysqli = $this->mysqliInit(); + if (defined('MYSQLI_CLIENT_SSL') && $this->fileExists($pathToCerts . "mysql-ca")) { $mysqlSsl = true; if ( - file_exists($pathToCerts . "mysql-key") && - file_exists($pathToCerts . "mysql-cert") + $this->fileExists($pathToCerts . "mysql-key") && + $this->fileExists($pathToCerts . "mysql-cert") ) { // with client side certificate/key - mysqli_ssl_set( + $this->mysqliSslSet( $mysqli, $pathToCerts . "mysql-key", $pathToCerts . "mysql-cert", @@ -1394,7 +1559,7 @@ private function connect_to_database($server, $user, $password, $port, $dbname = ); } else { // without client side certificate/key - mysqli_ssl_set( + $this->mysqliSslSet( $mysqli, null, null, @@ -1405,11 +1570,16 @@ private function connect_to_database($server, $user, $password, $port, $dbname = } } try { - if ($mysqlSsl) { - $ok = mysqli_real_connect($mysqli, $server, $user, $password, $dbname, (int)$port != 0 ? (int)$port : 3306, '', MYSQLI_CLIENT_SSL); - } else { - $ok = mysqli_real_connect($mysqli, $server, $user, $password, $dbname, (int)$port != 0 ? (int)$port : 3306); - } + $ok = $this->mysqliRealConnect( + $mysqli, + $server, + $user, + $password, + $dbname, + (int)$port != 0 ? (int)$port : 3306, + '', + $mysqlSsl ? MYSQLI_CLIENT_SSL : 0 + ); } catch (mysqli_sql_exception $e) { $this->error_message = "unable to connect to sql server because of mysql error: " . $e->getMessage(); return false; @@ -1421,25 +1591,42 @@ private function connect_to_database($server, $user, $password, $port, $dbname = return $mysqli; } - private function set_sql_strict() + /** + * Disable strict SQL mode for installation. + * + * Turns off MySQL strict mode to allow legacy SQL patterns + * during installation. + * + * @return mysqli_result|bool Result of SQL execution + */ + protected function set_sql_strict() { // Turn off STRICT SQL return $this->execute_sql("SET sql_mode = ''"); } - private function set_collation() + /** + * Set database character encoding to UTF8MB4. + * + * Configures the connection to use UTF8MB4 character set + * for proper Unicode support. + * + * @return mysqli_result|bool Result of SQL execution + */ + protected function set_collation() { return $this->execute_sql("SET NAMES 'utf8mb4'"); } - /** - * innitialize $this->dumpfiles, an array of the dumpfiles that will - * be loaded into the database, including the correct translation - * dumpfile. - * The keys are the paths of the dumpfiles, and the values are the titles - * @return array - */ - private function initialize_dumpfile_list() + /** + * Initialize $this->dumpfiles, an array of the dumpfiles that will + * be loaded into the database, including the correct translation + * dumpfile. + * The keys are the paths of the dumpfiles, and the values are the titles + * + * @return array + */ + protected function initialize_dumpfile_list(): array { if ($this->clone_database) { $this->dumpfiles = array( $this->get_backup_filename() => 'clone database' ); @@ -1458,7 +1645,7 @@ private function initialize_dumpfile_list() } // Load CVX codes if present - if (file_exists($this->cvx)) { + if ($this->fileExists($this->cvx)) { $dumpfiles[ $this->cvx ] = "CVX Immunization Codes"; } @@ -1468,15 +1655,15 @@ private function initialize_dumpfile_list() return $this->dumpfiles; } - /** - * - * Directory copy logic borrowed from a user comment at - * http://www.php.net/manual/en/function.copy.php - * @param string $src name of the directory to copy - * @param string $dst name of the destination to copy to - * @return bool indicating success - */ - private function recurse_copy($src, $dst) + /** + * Directory copy logic borrowed from a user comment at + * http://www.php.net/manual/en/function.copy.php + * + * @param string $src name of the directory to copy + * @param string $dst name of the destination to copy to + * @return bool indicating success + */ + protected function recurse_copy(string $src, string $dst): bool { $dir = opendir($src); if (! @mkdir($dst)) { @@ -1498,13 +1685,13 @@ private function recurse_copy($src, $dst) return true; } - /** - * - * dump a site's database to a temporary file. - * @param string $source_site_id the site_id of the site to dump - * @return filename of the backup - */ - private function dumpSourceDatabase() + /** + * Dump a site's database to a temporary file. + * + * @param string $source_site_id the site_id of the site to dump + * @return string filename of the backup + */ + protected function dumpSourceDatabase(): string { global $OE_SITES_BASE; $source_site_id = $this->source_site_id; @@ -1512,7 +1699,7 @@ private function dumpSourceDatabase() include("$OE_SITES_BASE/$source_site_id/sqlconf.php"); if (empty($config)) { - die("Source site $source_site_id has not been set up!"); + $this->die("Source site $source_site_id has not been set up!"); } /** @@ -1531,16 +1718,16 @@ private function dumpSourceDatabase() $tmp1 = []; $tmp0 = exec($cmd, $tmp1, $tmp2); if ($tmp2) { - die("Error $tmp2 running \"$cmd\": $tmp0 " . implode(' ', $tmp1)); + $this->die("Error $tmp2 running \"$cmd\": $tmp0 " . implode(' ', $tmp1)); } return $backup_file; } - /** - * @return filename of the source backup database for cloning - */ - private function get_backup_filename() + /** + * @return string filename of the source backup database for cloning + */ + protected function get_backup_filename(): string { if (stristr(PHP_OS, 'WIN')) { $backup_file = 'C:/windows/temp/setup_dump.sql'; @@ -1550,14 +1737,27 @@ private function get_backup_filename() return $backup_file; } - //RP_ADDED + + /** + * Get the currently selected theme. + * + * @return string Current theme name from globals table + */ public function getCurrentTheme() { - $current_theme = $this->execute_sql("SELECT gl_value FROM globals WHERE gl_name LIKE '%css_header%'"); - $current_theme = mysqli_fetch_array($current_theme); + $current_theme = $this->execute_sql("SELECT gl_value FROM globals WHERE gl_name LIKE '%css_header%'"); + $current_theme = $this->mysqliFetchArray($current_theme); return $current_theme[0]; } + /** + * Set the current theme in the database. + * + * Updates the globals table with the selected theme. + * For cloned sites, uses current theme if no new theme specified. + * + * @return mysqli_result|bool Result of the update operation + */ public function setCurrentTheme() { $current_theme = $this->getCurrentTheme(); @@ -1568,16 +1768,23 @@ public function setCurrentTheme() return $this->execute_sql("UPDATE globals SET gl_value='" . $this->escapeSql($this->new_theme) . "' WHERE gl_name LIKE '%css_header%'"); } - public function listThemes() + /** + * Get list of available themes. + * + * Scans the themes directory and returns available theme files. + * + * @return array List of theme file names + */ + public function listThemes(): array { $themes_img_dir = "public/images/stylesheets/"; - $arr_themes_img = array_values(array_filter(scandir($themes_img_dir), function ($item) { + $arr_themes_img = array_values(array_filter($this->scanDir($themes_img_dir), function ($item) { return $item[0] !== '.'; })); return $arr_themes_img; } - private function extractFileName($theme_file_name = '') + protected function extractFileName(string $theme_file_name = ''): array { $under_score = strpos($theme_file_name, '_') + 1; $dot = strpos($theme_file_name, '.'); @@ -1586,7 +1793,15 @@ private function extractFileName($theme_file_name = '') return array('theme_value' => $theme_value, 'theme_title' => $theme_title); } - public function displayThemesDivs() + /** + * Display HTML divs for theme selection interface. + * + * Generates radio button interface with theme preview images + * for the installation theme selection step. + * + * @return void + */ + public function displayThemesDivs(): void { $themes_number = count($this->listThemes()); for ($i = 0; $i < $themes_number; $i++) { @@ -1631,10 +1846,16 @@ public function displayThemesDivs() break; } } - return; } - public function displaySelectedThemeDiv() + /** + * Display the currently selected theme information. + * + * Shows theme preview and details for the currently active theme. + * + * @return void + */ + public function displaySelectedThemeDiv(): void { $theme_file_name = $this->getCurrentTheme(); $arr_extracted_file_name = $this->extractFileName($theme_file_name); @@ -1656,10 +1877,17 @@ public function displaySelectedThemeDiv()
DSTD; echo $display_selected_theme_div . "\r\n"; - return; } - public function displayNewThemeDiv() + /** + * Display the newly selected theme information. + * + * Shows preview of the theme that will be applied after installation. + * For cloned sites, defaults to current theme if no new theme selected. + * + * @return void + */ + public function displayNewThemeDiv(): void { // cloned sites don't get a chance to set a new theme if (!$this->new_theme) { @@ -1684,10 +1912,17 @@ public function displayNewThemeDiv()
DSTD; echo $display_selected_theme_div . "\r\n"; - return; } - public function setupHelpModal() + /** + * Display the installation help modal dialog. + * + * Generates HTML and JavaScript for a modal popup that shows + * installation help documentation in an iframe. + * + * @return void + */ + public function setupHelpModal(): void { $setup_help_modal = << @@ -1727,6 +1962,376 @@ public function setupHelpModal() SETHLP; echo $setup_help_modal . "\r\n"; - return; + } + + /** + * Wrapper for feof to facilitate unit testing. + * + * @codeCoverageIgnore + * + * @param resource $stream + * @return bool + */ + protected function atEndOfFile($stream): bool + { + return feof($stream); + } + + /** + * Wrapper for fclose to facilitate unit testing. + * + * @codeCoverageIgnore + * + * @param resource $stream + * @return bool + */ + protected function closeFile($stream): bool + { + return fclose($stream); + } + + /** + * Create a new Totp instance. + * + * @codeCoverageIgnore + * + * @param string $secret The TOTP secret + * @param string $user The username + * @return Totp + */ + protected function createTotpInstance(string $secret, string $user): Totp + { + return new Totp($secret, $user); + } + + /** + * Check if OpenEMR CryptoGen class exists. + * + * @codeCoverageIgnore + * + * @return bool + */ + protected function cryptoGenClassExists(): bool + { + return class_exists('OpenEMR\Common\Crypto\CryptoGen'); + } + + /** + * Wrapper for die() to facilitate unit testing. + * + * @codeCoverageIgnore + * + * @return never + */ + protected function die(string $message): never + { + error_log($message); + die($message); + } + + /** + * Close the mysqli connection. + * + * @codeCoverageIgnore + * + * @return true + */ + public function disconnect(): true + { + return mysqli_close($this->dbh); + } + + /** + * Encrypt TOTP secret using CryptoGen. + * + * @codeCoverageIgnore + * + * @param string $secret The TOTP secret to encrypt + * @param string $hash The password hash to use for encryption + * @return string Encrypted secret + */ + protected function encryptTotpSecret(string $secret, string $hash): string + { + $cryptoGen = new \OpenEMR\Common\Crypto\CryptoGen(); + return $cryptoGen->encryptStandard($secret, $hash); + } + + /** + * Escape SQL strings to prevent injection attacks. + * + * @codeCoverageIgnore + * + * @param string $sql SQL string to escape + * @return string Escaped SQL string + */ + protected function escapeSql(string $sql): string + { + return mysqli_real_escape_string($this->dbh, $sql); + } + + /** + * Wrapper for file_exists to facilitate unit testing. + * + * @codeCoverageIgnore + * + * @param string $fileName + * @return bool + */ + protected function fileExists(string $fileName): bool + { + return file_exists($fileName); + } + + /** + * Wrapper for fgets to facilitate unit testing. + * + * @codeCoverageIgnore + * + * @param resource $stream + * @param int $length + * @return string|false + */ + protected function getLine($stream, int $length): string|false + { + return fgets($stream, $length); + } + + /** + * Wrapper for glob to facilitate unit testing. + * + * @codeCoverageIgnore + * + * @param string $pattern + * @param int $flags + * @return array|false + */ + protected function globPattern(string $pattern, int $flags = 0): array|false + { + return glob($pattern, $flags); + } + + /** + * Wrapper for mysqli_errno to facilitate unit testing. + * + * @codeCoverageIgnore + * + * @param mysqli $mysql + * @return int + */ + protected function mysqliErrno(mysqli $mysql): int + { + return mysqli_errno($mysql); + } + + /** + * Wrapper for mysqli_error to facilitate unit testing. + * + * @codeCoverageIgnore + * + * @param mysqli $mysql + * @return string + */ + protected function mysqliError(mysqli $mysql): string + { + return mysqli_error($mysql); + } + + /** + * Wrapper for mysqli_fetch_array to facilitate unit testing. + * + * @codeCoverageIgnore + * + * @param mysqli_result $result + * @param int $mode + * @return array|null|false + */ + protected function mysqliFetchArray(mysqli_result $result, int $mode = MYSQLI_BOTH): array|null|false + { + return mysqli_fetch_array($result, $mode); + } + + /** + * Wrapper for mysqli_init to facilitate unit testing. + * + * @codeCoverageIgnore + * + * @return mysqli|false + */ + protected function mysqliInit(): mysqli|false + { + return mysqli_init(); + } + + /** + * Wrapper for mysqli_connect to facilitate unit testing. + * + * @codeCoverageIgnore + * + * @param mysqli_result $result + * @return int + */ + protected function mysqliNumRows(mysqli_result $result): int + { + return mysqli_num_rows($result); + } + + /** + * Wrapper for mysqli_query to facilitate unit testing. + * + * @codeCoverageIgnore + * + * @param mysqli $mysql + * @param string $query + * @return mysqli_result|bool + */ + protected function mysqliQuery(mysqli $mysql, string $query): mysqli_result|bool + { + return mysqli_query($mysql, $query); + } + + /** + * Wrapper for mysqli_real_connect to facilitate unit testing. + * + * @codeCoverageIgnore + * + * @param mysqli $link + * @param string $host + * @param string $user + * @param string $password + * @param string $database + * @param int $port + * @param string $socket + * @param int $flags + * @return bool + */ + protected function mysqliRealConnect(mysqli $link, string $host, string $user, string $password, string $database = '', int $port = 0, string $socket = '', int $flags = 0): bool + { + return mysqli_real_connect($link, $host, $user, $password, $database, $port, $socket, $flags); + } + + /** + * Wrapper for mysqli_connect to facilitate unit testing. + * + * @codeCoverageIgnore + * + * @param mysqli $mysql + * @param string $dbname + * @return bool + */ + protected function mysqliSelectDb(mysqli $mysql, string $dbname): bool + { + return mysqli_select_db($mysql, $dbname); + } + + /** + * Wrapper for mysqli_ssl_set to facilitate unit testing. + * + * @codeCoverageIgnore + * + * @param mysqli $link + * @param ?string $key + * @param ?string $cert + * @param ?string $ca + * @param ?string $capath + * @param ?string $cipher + * @return bool + */ + protected function mysqliSslSet(mysqli $link, ?string $key, ?string $cert, ?string $ca, ?string $capath, ?string $cipher): bool + { + return mysqli_ssl_set($link, $key, $cert, $ca, $capath, $cipher); + } + + /** + * Create a new instance of the GaclApi class. + * + * @codeCoverageIgnore + * + * @return GaclApi New instance of GaclApi + */ + protected function newGaclApi(): GaclApi + { + return new GaclApi(); + } + + /** + * Wrapper for fopen to facilitate unit testing. + * + * @codeCoverageIgnore + * + * @param string $filename + * @param string $mode + * @return resource|false + */ + protected function openFile(string $filename, string $mode) + { + return fopen($filename, $mode); + } + + /** + * Wrapper for scandir to facilitate unit testing. + * + * @codeCoverageIgnore + * + * @param string $directory + * @return array|false + */ + protected function scanDir(string $directory) + { + return scandir($directory); + } + + /** + * Check if Totp class exists. + * + * @codeCoverageIgnore + * + * @return bool + */ + protected function totpClassExists(): bool + { + return class_exists('Totp'); + } + + /** + * Wrapper for touch to facilitate unit testing. + * + * @codeCoverageIgnore + * + * @param string $filename + * @param ?int $mtime + * @param ?int $atime + * @return bool + */ + protected function touchFile(string $filename, ?int $mtime = null, ?int $atime = null): bool + { + return touch($filename, $mtime, $atime); + } + + /** + * Wrapper for unlink to facilitate unit testing. + * + * @codeCoverageIgnore + * + * @param string $filename + * @return bool + */ + protected function unlinkFile(string $filename): bool + { + return unlink($filename); + } + + /** + * Wrapper for fwrite to facilitate unit testing. + * + * @codeCoverageIgnore + * + * @param resource $stream + * @param string $data + * @param ?int $length + * @return int|false + */ + protected function writeToFile($stream, string $data, ?int $length = null): int|false + { + return $length !== null ? fwrite($stream, $data, $length) : fwrite($stream, $data); } } diff --git a/tests/Tests/Isolated/library/classes/InstallerTest.php b/tests/Tests/Isolated/library/classes/InstallerTest.php new file mode 100644 index 000000000000..53cd28ab397b --- /dev/null +++ b/tests/Tests/Isolated/library/classes/InstallerTest.php @@ -0,0 +1,3742 @@ + 'localhost', + 'root' => 'root', + 'rootpass' => 'password', + 'port' => '3306', + 'login' => 'openemr', + 'pass' => 'openemr', + 'dbname' => 'openemr', + 'iuser' => 'openemr', + 'iuname' => 'Administrator', + 'iuserpass' => 'admin', + 'igroup' => 'Default' + ]; + + $config = array_merge($defaultConfig, $config); + $defaultMockMethods = [ + 'atEndOfFile', + 'closeFile', + 'createTotpInstance', + 'cryptoGenClassExists', + 'die', + 'encryptTotpSecret', + 'escapeSql', + 'execute_sql', + 'fileExists', + 'getLine', + 'globPattern', + 'load_file', + 'mysqliErrno', + 'mysqliError', + 'mysqliFetchArray', + 'mysqliInit', + 'mysqliNumRows', + 'mysqliQuery', + 'mysqliRealConnect', + 'mysqliSelectDb', + 'mysqliSslSet', + 'newGaclApi', + 'openFile', + 'recurse_copy', + 'scanDir', + 'set_collation', + 'set_sql_strict', + 'totpClassExists', + 'touchFile', + 'unlinkFile', + 'writeToFile', + ]; + $mockMethods = array_unique(array_merge($defaultMockMethods, $mockMethods)); + + return $this->getMockBuilder(Installer::class) + ->setConstructorArgs([$config]) + ->onlyMethods($mockMethods) + ->getMock(); + } + + protected function setUp(): void + { + $installSettings = [ + 'iuser' => 'admin', + 'iuname' => 'Administrator', + 'iuserpass' => 'pass', + 'igroup' => 'Default', + 'server' => 'localhost', // mysql server + 'loginhost' => 'localhost', // php/apache server + 'port' => '3306', + 'root' => 'root', + 'rootpass' => 'hunter2', + 'login' => 'openemr', + 'pass' => 'openemr', + 'dbname' => 'openemr', + 'collate' => 'utf8mb4_general_ci', + 'site' => 'default', + 'source_site_id' => 'default', + ]; + + $this->installer = new Installer($installSettings); + } + + public function testLoginIsValid(): void + { + $this->assertTrue($this->installer->login_is_valid()); + $this->installer->login = ''; + $this->assertFalse($this->installer->login_is_valid()); + } + + public function testCharIsValid(): void + { + $this->assertFalse($this->installer->char_is_valid(' ')); + $this->assertTrue($this->installer->char_is_valid('happy path')); + $badChars = ['\\', ';', '(', ')', '<', '>', '/', '"', "'"]; + foreach ($badChars as $badChar) { + $this->assertFalse($this->installer->char_is_valid($badChar), "Failed asserting that '{$badChar}' is invalid"); + } + } + + public function testDatabaseNameIsValid(): void + { + $this->assertTrue($this->installer->databaseNameIsValid('12345')); + $this->assertFalse($this->installer->databaseNameIsValid('@12345')); + } + + public function testCollateNameIsValid(): void + { + $this->assertTrue($this->installer->collateNameIsValid('utf8mb4_general_ci')); + $this->assertFalse($this->installer->collateNameIsValid('@utf8mb4_general_ci')); + } + + public function testIuserIsValid(): void + { + $this->assertTrue($this->installer->iuser_is_valid()); + // whitespace is not allowed + $this->installer->iuser = 'roger felton'; + $this->assertFalse($this->installer->iuser_is_valid()); + } + + public function testIunameIsValid(): void + { + $this->assertTrue($this->installer->iuname_is_valid()); + $this->installer->iuname = ''; + $this->assertFalse($this->installer->iuname_is_valid()); + } + + public function testPasswordIsValid(): void + { + $this->assertTrue($this->installer->password_is_valid()); + $this->installer->pass = ''; + $this->assertFalse($this->installer->password_is_valid()); + } + + public function testUserPasswordIsValid(): void + { + $this->assertTrue($this->installer->user_password_is_valid()); + $this->installer->iuserpass = ''; + $this->assertFalse($this->installer->user_password_is_valid()); + } + + public function testRootDatabaseConnectionSuccess(): void + { + $mockInstaller = $this->createMockInstaller([], ['connect_to_database']); + $mockMysqli = $this->createMock(mysqli::class); + + $mockInstaller->expects($this->once()) + ->method('connect_to_database') + ->with('localhost', 'root', 'password', '3306') + ->willReturn($mockMysqli); + + $mockInstaller->expects($this->once()) + ->method('set_sql_strict') + ->willReturn(true); + + $result = $mockInstaller->root_database_connection(); + + $this->assertTrue($result); + $this->assertEquals($mockMysqli, $mockInstaller->dbh); + } + + public function testRootDatabaseConnectionFailsWhenConnectionFails(): void + { + $mockInstaller = $this->createMockInstaller([], ['connect_to_database']); + + $mockInstaller->expects($this->once()) + ->method('connect_to_database') + ->with('localhost', 'root', 'password', '3306') + ->willReturn(false); + + $mockInstaller->expects($this->never()) + ->method('set_sql_strict'); + + $result = $mockInstaller->root_database_connection(); + + $this->assertFalse($result); + $this->assertEquals('unable to connect to database as root', $mockInstaller->error_message); + } + + public function testRootDatabaseConnectionFailsWhenSqlStrictFails(): void + { + $mockInstaller = $this->createMockInstaller([], ['connect_to_database']); + $mockMysqli = $this->createMock(mysqli::class); + + $mockInstaller->expects($this->once()) + ->method('connect_to_database') + ->with('localhost', 'root', 'password', '3306') + ->willReturn($mockMysqli); + + $mockInstaller->expects($this->once()) + ->method('set_sql_strict') + ->willReturn(false); + + $result = $mockInstaller->root_database_connection(); + + $this->assertFalse($result); + $this->assertEquals('unable to set strict sql setting', $mockInstaller->error_message); + $this->assertEquals($mockMysqli, $mockInstaller->dbh); + } + + public function testRootDatabaseConnectionWithSSLCertificates(): void + { + $mockInstaller = $this->createMockInstaller(['site' => 'default'], ['connect_to_database']); + $mockMysqli = $this->createMock(mysqli::class); + + $mockInstaller->expects($this->once()) + ->method('connect_to_database') + ->with('localhost', 'root', 'password', '3306') + ->willReturn($mockMysqli); + + $mockInstaller->expects($this->once()) + ->method('set_sql_strict') + ->willReturn(true); + + $result = $mockInstaller->root_database_connection(); + + $this->assertTrue($result); + $this->assertEquals($mockMysqli, $mockInstaller->dbh); + } + + public function testUserDatabaseConnectionSuccess(): void + { + $mockInstaller = $this->createMockInstaller([], ['connect_to_database']); + $mockMysqli = $this->createMock(mysqli::class); + + $mockInstaller->expects($this->once()) + ->method('connect_to_database') + ->with('localhost', 'openemr', 'openemr', '3306', 'openemr') + ->willReturn($mockMysqli); + + $mockInstaller->expects($this->once()) + ->method('set_sql_strict') + ->willReturn(true); + + $mockInstaller->expects($this->once()) + ->method('set_collation') + ->willReturn(true); + + $mockInstaller->expects($this->once()) + ->method('mysqliSelectDb') + ->with($mockMysqli, 'openemr') + ->willReturn(true); + + $result = $mockInstaller->user_database_connection(); + + $this->assertTrue($result); + $this->assertEquals($mockMysqli, $mockInstaller->dbh); + } + + public function testUserDatabaseConnectionFailsWhenConnectionFails(): void + { + $mockInstaller = $this->createMockInstaller([], ['connect_to_database']); + + $mockInstaller->expects($this->once()) + ->method('connect_to_database') + ->with('localhost', 'openemr', 'openemr', '3306', 'openemr') + ->willReturn(false); + + $result = $mockInstaller->user_database_connection(); + + $this->assertFalse($result); + $this->assertEquals("unable to connect to database as user: 'openemr'", $mockInstaller->error_message); + } + + public function testUserDatabaseConnectionFailsWhenSqlStrictFails(): void + { + $mockInstaller = $this->createMockInstaller([], ['connect_to_database']); + $mockMysqli = $this->createMock(mysqli::class); + + $mockInstaller->expects($this->once()) + ->method('connect_to_database') + ->with('localhost', 'openemr', 'openemr', '3306', 'openemr') + ->willReturn($mockMysqli); + + $mockInstaller->expects($this->once()) + ->method('set_sql_strict') + ->willReturn(false); + + $result = $mockInstaller->user_database_connection(); + + $this->assertFalse($result); + $this->assertEquals('unable to set strict sql setting', $mockInstaller->error_message); + } + + public function testUserDatabaseConnectionFailsWhenCollationFails(): void + { + $mockInstaller = $this->createMockInstaller([], ['connect_to_database']); + $mockMysqli = $this->createMock(mysqli::class); + + $mockInstaller->expects($this->once()) + ->method('connect_to_database') + ->with('localhost', 'openemr', 'openemr', '3306', 'openemr') + ->willReturn($mockMysqli); + + $mockInstaller->expects($this->once()) + ->method('set_sql_strict') + ->willReturn(true); + + $mockInstaller->expects($this->once()) + ->method('set_collation') + ->willReturn(false); + + $result = $mockInstaller->user_database_connection(); + + $this->assertFalse($result); + $this->assertEquals('unable to set sql collation', $mockInstaller->error_message); + } + + public function testUserDatabaseConnectionFailsWhenSelectDbFails(): void + { + $mockInstaller = $this->createMockInstaller([], ['connect_to_database']); + $mockMysqli = $this->createMock(mysqli::class); + + $mockInstaller->expects($this->once()) + ->method('connect_to_database') + ->with('localhost', 'openemr', 'openemr', '3306', 'openemr') + ->willReturn($mockMysqli); + + $mockInstaller->expects($this->once()) + ->method('set_sql_strict') + ->willReturn(true); + + $mockInstaller->expects($this->once()) + ->method('set_collation') + ->willReturn(true); + + $mockInstaller->expects($this->once()) + ->method('mysqliSelectDb') + ->with($mockMysqli, 'openemr') + ->willReturn(false); + + $result = $mockInstaller->user_database_connection(); + + $this->assertFalse($result); + $this->assertEquals("unable to select database: 'openemr'", $mockInstaller->error_message); + } + + public function testCreateDatabaseSuccess(): void + { + $mockInstaller = $this->createMockInstaller(); + + $mockInstaller->expects($this->once()) + ->method('set_collation') + ->willReturn(true); + + $mockInstaller->expects($this->once()) + ->method('execute_sql') + ->with("create database openemr character set utf8mb4 collate utf8mb4_general_ci") + ->willReturn(true); + + $result = $mockInstaller->create_database(); + + $this->assertTrue($result); + } + + public function testCreateDatabaseWithCustomCollation(): void + { + $mockInstaller = $this->createMockInstaller(['collate' => 'utf8mb4_unicode_ci']); + + $mockInstaller->expects($this->once()) + ->method('set_collation') + ->willReturn(true); + + $mockInstaller->expects($this->once()) + ->method('execute_sql') + ->with("create database openemr character set utf8mb4 collate utf8mb4_unicode_ci") + ->willReturn(true); + + $result = $mockInstaller->create_database(); + + $this->assertTrue($result); + } + + public function testCreateDatabaseWithLegacyCollation(): void + { + $mockInstaller = $this->createMockInstaller(['collate' => 'utf8_general_ci']); + + $mockInstaller->expects($this->once()) + ->method('set_collation') + ->willReturn(true); + + $mockInstaller->expects($this->once()) + ->method('execute_sql') + ->with("create database openemr character set utf8mb4 collate utf8mb4_general_ci") + ->willReturn(true); + + $result = $mockInstaller->create_database(); + + $this->assertTrue($result); + // Verify that legacy collation was updated + $this->assertEquals('utf8mb4_general_ci', $mockInstaller->collate); + } + + public function testCreateDatabaseFailsWhenExecuteSqlFails(): void + { + $mockInstaller = $this->createMockInstaller(); + + $mockInstaller->expects($this->once()) + ->method('set_collation') + ->willReturn(true); + + $mockInstaller->expects($this->once()) + ->method('execute_sql') + ->with("create database openemr character set utf8mb4 collate utf8mb4_general_ci") + ->willReturn(false); + + $result = $mockInstaller->create_database(); + + $this->assertFalse($result); + } + + public function testCreateDatabaseWithDifferentDbName(): void + { + $mockInstaller = $this->createMockInstaller(['dbname' => 'test_db']); + + $mockInstaller->expects($this->once()) + ->method('set_collation') + ->willReturn(true); + + $mockInstaller->expects($this->once()) + ->method('execute_sql') + ->with("create database test_db character set utf8mb4 collate utf8mb4_general_ci") + ->willReturn(true); + + $result = $mockInstaller->create_database(); + + $this->assertTrue($result); + } + + public function testCreateDatabaseWithInvalidCollationName(): void + { + // Create installer with invalid collation name containing illegal characters + $mockInstaller = $this->createMockInstaller(['collate' => 'utf8@invalid!']); + + // create_database will die before getting here. + $mockInstaller->expects($this->never()) + ->method('set_collation'); + + // Expect die() to be called with the error message for invalid collation + $mockInstaller->expects($this->once()) + ->method('die') + ->with('Illegal character(s) in collation name') + ->willThrowException(new \Exception('Die called with: Illegal character(s) in collation name')); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Die called with: Illegal character(s) in collation name'); + + // This should trigger die() in escapeCollateName when it validates the collation name + $mockInstaller->create_database(); + } + + public function testDropDatabase(): void + { + $mockInstaller = $this->createMockInstaller(); + + $mockInstaller->expects($this->once()) + ->method('execute_sql') + ->with('drop database if exists openemr') + ->willReturn(true); + + $result = $mockInstaller->drop_database(); + + $this->assertTrue($result); + } + + public function testCreateDatabaseUserWhenUserDoesNotExist(): void + { + $mockInstaller = $this->createMockInstaller(['loginhost' => 'localhost']); + + $mockInstaller->expects($this->exactly(3)) + ->method('escapeSql') + ->willReturnArgument(0); + + $mockResult = $this->createMock(mysqli_result::class); + + $callCount = 0; + $mockInstaller->expects($this->exactly(2)) + ->method('execute_sql') + ->willReturnCallback(function ($sql) use (&$callCount, $mockResult) { + $callCount++; + if ($callCount === 1 && strpos($sql, 'SELECT user FROM mysql.user') !== false) { + return $mockResult; + } + return true; + }); + + $mockInstaller->expects($this->once()) + ->method('mysqliNumRows') + ->with($mockResult) + ->willReturn(0); + + $result = $mockInstaller->create_database_user(); + + $this->assertTrue($result); + } + + public function testCreateDatabaseUserWhenUserExists(): void + { + $mockInstaller = $this->createMockInstaller(['loginhost' => 'localhost']); + + $mockInstaller->expects($this->exactly(3)) + ->method('escapeSql') + ->willReturnArgument(0); + + $mockResult = $this->createMock(mysqli_result::class); + + $callCount = 0; + $mockInstaller->expects($this->exactly(2)) + ->method('execute_sql') + ->willReturnCallback(function ($sql) use (&$callCount, $mockResult) { + $callCount++; + if ($callCount === 1 && strpos($sql, 'SELECT user FROM mysql.user') !== false) { + return $mockResult; + } + return true; + }); + + $mockInstaller->expects($this->once()) + ->method('mysqliNumRows') + ->with($mockResult) + ->willReturn(1); + + $result = $mockInstaller->create_database_user(); + + $this->assertTrue($result); + } + + public function testCreateDatabaseUserWhenCheckUserFails(): void + { + $mockInstaller = $this->createMockInstaller(['loginhost' => 'localhost']); + + $mockInstaller->expects($this->exactly(3)) + ->method('escapeSql') + ->willReturnArgument(0); + + $mockInstaller->expects($this->exactly(2)) + ->method('execute_sql') + ->willReturn(false); + + $result = $mockInstaller->create_database_user(); + + $this->assertFalse($result); + } + + public function testGrantPrivileges(): void + { + $mockInstaller = $this->createMockInstaller(['dbname' => 'testdb', 'login' => 'testuser', 'loginhost' => 'localhost']); + + $mockInstaller->expects($this->exactly(2)) + ->method('escapeSql') + ->willReturnArgument(0); + + $mockInstaller->expects($this->once()) + ->method('execute_sql') + ->with("GRANT ALL PRIVILEGES ON testdb.* TO 'testuser'@'localhost'") + ->willReturn(true); + + $result = $mockInstaller->grant_privileges(); + + $this->assertTrue($result); + } + + public function testGrantPrivilegesWithInvalidDatabaseName(): void + { + // Create installer with invalid database name containing illegal characters + $mockInstaller = $this->createMockInstaller(['dbname' => 'test$db!', 'login' => 'testuser', 'loginhost' => 'localhost']); + + // grant_privileges will die before calling escapeSql + $mockInstaller->expects($this->never()) + ->method('escapeSql'); + + $mockInstaller->expects($this->once()) + ->method('die') + ->with('Illegal character(s) in database name') + ->willThrowException(new \Exception('Die called with: Illegal character(s) in database name')); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Die called with: Illegal character(s) in database name'); + + // This should trigger die() in escapeDatabaseName when it validates the database name + $mockInstaller->grant_privileges(); + } + + public function testLoadDumpfilesSuccess(): void + { + $mockInstaller = $this->createMockInstaller(); + + // Set up dumpfiles array + $mockInstaller->dumpfiles = [ + '/path/to/main.sql' => 'Main Database', + '/path/to/translations.sql' => 'Language Translations' + ]; + + $callCount = 0; + $mockInstaller->expects($this->exactly(2)) + ->method('load_file') + ->willReturnCallback(function ($filename, $title) use (&$callCount) { + $callCount++; + if ($callCount === 1) { + return "Creating Main Database tables...\nOK.
\n"; + } else { + return "Creating Language Translations tables...\nOK.
\n"; + } + }); + + $result = $mockInstaller->load_dumpfiles(); + + $expectedResult = "Creating Main Database tables...\nOK.
\nCreating Language Translations tables...\nOK.
\n"; + $this->assertEquals($expectedResult, $result); + } + + public function testQuickInstallSuccess(): void + { + $mockInstaller = $this->createMockInstaller([], [ + 'add_initial_user', + 'add_version_info', + 'create_database', + 'create_database_user', + 'create_dumpfiles', + 'create_site_directory', + 'disconnect', + 'grant_privileges', + 'install_additional_users', + 'install_gacl', + 'insert_globals', + 'iuser_is_valid', + 'load_dumpfiles', + 'login_is_valid', + 'on_care_coordination', + 'password_is_valid', + 'root_database_connection', + 'user_database_connection', + 'user_password_is_valid', + 'write_configuration_file' + ]); + + // Mock all validation methods to return true + $mockInstaller->expects($this->once())->method('login_is_valid')->willReturn(true); + $mockInstaller->expects($this->once())->method('iuser_is_valid')->willReturn(true); + $mockInstaller->expects($this->once())->method('user_password_is_valid')->willReturn(true); + $mockInstaller->expects($this->once())->method('password_is_valid')->willReturn(true); + + // Mock database connection methods + $mockInstaller->expects($this->exactly(2))->method('root_database_connection')->willReturn(true); + $mockInstaller->expects($this->exactly(2))->method('user_database_connection')->willReturnOnConsecutiveCalls(false, true); + $mockInstaller->expects($this->exactly(2))->method('disconnect')->willReturn(true); + + // Mock database setup methods + $mockInstaller->expects($this->once())->method('create_database')->willReturn(true); + $mockInstaller->expects($this->once())->method('create_database_user')->willReturn(true); + $mockInstaller->expects($this->once())->method('grant_privileges')->willReturn(true); + + // Mock configuration and setup methods + $mockInstaller->expects($this->once())->method('load_dumpfiles')->willReturn("Creating Main Database tables...\nOK.
\nCreating Language Translations tables...\nOK.
\n"); + $mockInstaller->expects($this->once())->method('write_configuration_file')->willReturn(true); + $mockInstaller->expects($this->once())->method('add_version_info')->willReturn(true); + $mockInstaller->expects($this->once())->method('insert_globals')->willReturn(true); + $mockInstaller->expects($this->once())->method('add_initial_user')->willReturn(true); + $mockInstaller->expects($this->once())->method('install_gacl')->willReturn(true); + $mockInstaller->expects($this->once())->method('install_additional_users')->willReturn(true); + $mockInstaller->expects($this->once())->method('on_care_coordination')->willReturn(true); + + $result = $mockInstaller->quick_install(); + + $this->assertTrue($result); + } + + public function testQuickInstallFailsOnLoginValidation(): void + { + $mockInstaller = $this->createMockInstaller([], ['login_is_valid']); + + $mockInstaller->expects($this->once())->method('login_is_valid')->willReturn(false); + + $result = $mockInstaller->quick_install(); + + $this->assertFalse($result); + } + + public function testQuickInstallWithCloneDatabaseSkipsValidation(): void + { + $mockInstaller = $this->createMockInstaller(['clone_database' => 'source_db'], [ + 'add_initial_user', + 'add_version_info', + 'create_database', + 'create_database_user', + 'create_dumpfiles', + 'disconnect', + 'grant_privileges', + 'insert_globals', + 'install_additional_users', + 'install_gacl', + 'iuser_is_valid', + 'load_dumpfiles', + 'login_is_valid', + 'password_is_valid', + 'root_database_connection', + 'user_database_connection', + 'user_password_is_valid', + 'write_configuration_file' + ]); + + // Should not call user validation methods when cloning + $mockInstaller->expects($this->never())->method('login_is_valid'); + $mockInstaller->expects($this->never())->method('iuser_is_valid'); + $mockInstaller->expects($this->never())->method('user_password_is_valid'); + + $mockInstaller->expects($this->once())->method('password_is_valid')->willReturn(true); + $mockInstaller->expects($this->exactly(2))->method('root_database_connection')->willReturn(true); + $mockInstaller->expects($this->once())->method('create_dumpfiles')->willReturn("a string"); + $mockInstaller->expects($this->exactly(2))->method('user_database_connection')->willReturnOnConsecutiveCalls(false, true); + $mockInstaller->expects($this->exactly(2))->method('disconnect')->willReturn(true); + + $mockInstaller->expects($this->once())->method('create_database')->willReturn(true); + $mockInstaller->expects($this->once())->method('create_database_user')->willReturn(true); + $mockInstaller->expects($this->once())->method('grant_privileges')->willReturn(true); + $mockInstaller->expects($this->once())->method('load_dumpfiles')->willReturn("a string"); + $mockInstaller->expects($this->once())->method('write_configuration_file')->willReturn(true); + + // Should not call these methods when cloning + $mockInstaller->expects($this->never())->method('add_version_info'); + $mockInstaller->expects($this->never())->method('insert_globals'); + $mockInstaller->expects($this->never())->method('add_initial_user'); + $mockInstaller->expects($this->never())->method('install_gacl'); + $mockInstaller->expects($this->never())->method('install_additional_users'); + + $result = $mockInstaller->quick_install(); + + $this->assertTrue($result); + } + + public function testLoadDumpfilesFailure(): void + { + $mockInstaller = $this->createMockInstaller(); + + $mockInstaller->dumpfiles = [ + '/path/to/main.sql' => 'Main Database', + '/path/to/bad.sql' => 'Bad File' + ]; + + $callCount = 0; + $mockInstaller->expects($this->exactly(2)) + ->method('load_file') + ->willReturnCallback(function ($filename, $title) use (&$callCount) { + $callCount++; + if ($callCount === 1) { + return "Creating Main Database tables...\nOK.
\n"; + } else { + return false; + } + }); + + $result = $mockInstaller->load_dumpfiles(); + + $this->assertFalse($result); + } + + public function testLoadFileSuccessWithMockedMethods(): void + { + $mockInstaller = $this->getMockBuilder(Installer::class) + ->setConstructorArgs([[ + 'server' => 'localhost', + 'root' => 'root', + 'rootpass' => 'password', + 'port' => '3306', + 'login' => 'openemr', + 'pass' => 'openemr', + 'dbname' => 'openemr' + ]]) + ->onlyMethods(['openFile', 'atEndOfFile', 'getLine', 'execute_sql', 'closeFile']) + ->getMock(); + + $mockResource = fopen('php://memory', 'w+'); + + $mockInstaller->expects($this->once()) + ->method('openFile') + ->with('/path/to/test.sql', 'r') + ->willReturn($mockResource); + + $eofCallCount = 0; + $mockInstaller->expects($this->exactly(3)) + ->method('atEndOfFile') + ->willReturnCallback(function ($resource) use (&$eofCallCount) { + $eofCallCount++; + return $eofCallCount > 2; + }); + + $mockInstaller->expects($this->exactly(2)) + ->method('getLine') + ->with($mockResource, 1024) + ->willReturnOnConsecutiveCalls( + "CREATE TABLE users;", + "INSERT INTO users VALUES (1, 'admin');" + ); + + $mockInstaller->expects($this->exactly(6)) + ->method('execute_sql') + ->willReturn(true); + + $mockInstaller->expects($this->once()) + ->method('closeFile') + ->with($mockResource) + ->willReturn(true); + + $result = $mockInstaller->load_file('/path/to/test.sql', 'Test Database'); + + $this->assertIsString($result); + $this->assertStringContainsString('Creating Test Database tables', $result); + $this->assertStringContainsString('OK', $result); + } + + public function testLoadFileOpenFailure(): void + { + $mockInstaller = $this->getMockBuilder(Installer::class) + ->setConstructorArgs([[ + 'server' => 'localhost', + 'root' => 'root', + 'rootpass' => 'password', + 'port' => '3306', + 'login' => 'openemr', + 'pass' => 'openemr', + 'dbname' => 'openemr' + ]]) + ->onlyMethods(['openFile']) + ->getMock(); + + $mockInstaller->expects($this->once()) + ->method('openFile') + ->with('/path/to/missing.sql', 'r') + ->willReturn(false); + + $result = $mockInstaller->load_file('/path/to/missing.sql', 'Missing Database'); + + $this->assertFalse($result); + } + + public function testLoadFileSqlExecutionFailure(): void + { + $mockInstaller = $this->getMockBuilder(Installer::class) + ->setConstructorArgs([[ + 'server' => 'localhost', + 'root' => 'root', + 'rootpass' => 'password', + 'port' => '3306', + 'login' => 'openemr', + 'pass' => 'openemr', + 'dbname' => 'openemr' + ]]) + ->onlyMethods(['openFile', 'atEndOfFile', 'getLine', 'execute_sql']) + ->getMock(); + + $mockResource = fopen('php://memory', 'w+'); + + $mockInstaller->expects($this->once()) + ->method('openFile') + ->with('/path/to/test.sql', 'r') + ->willReturn($mockResource); + + $mockInstaller->expects($this->once()) + ->method('atEndOfFile') + ->willReturn(false); + + $mockInstaller->expects($this->once()) + ->method('getLine') + ->with($mockResource, 1024) + ->willReturn("CREATE TABLE users;"); + + $mockInstaller->expects($this->exactly(3)) + ->method('execute_sql') + ->willReturnOnConsecutiveCalls(true, true, false); + + $result = $mockInstaller->load_file('/path/to/test.sql', 'Test Database'); + + $this->assertFalse($result); + } + + public function testAddVersionInfoSuccess(): void + { + $mockInstaller = $this->getMockBuilder(Installer::class) + ->setConstructorArgs([[ + 'server' => 'localhost', + 'root' => 'root', + 'rootpass' => 'password', + 'port' => '3306', + 'login' => 'openemr', + 'pass' => 'openemr', + 'dbname' => 'openemr' + ]]) + ->onlyMethods(['execute_sql', 'escapeSql', 'mysqliError']) + ->getMock(); + + $mockInstaller->expects($this->exactly(7)) + ->method('escapeSql') + ->willReturnArgument(0); + + $mockInstaller->expects($this->once()) + ->method('execute_sql') + ->with($this->stringContains('UPDATE version SET')) + ->willReturn(true); + + $result = $mockInstaller->add_version_info(); + + $this->assertTrue($result); + } + + public function testAddVersionInfoFailure(): void + { + $mockInstaller = $this->createMockInstaller(); + + $mockMysqli = $this->createMock(mysqli::class); + $mockInstaller->dbh = $mockMysqli; + + $mockInstaller->expects($this->exactly(7)) + ->method('escapeSql') + ->willReturnArgument(0); + + $mockInstaller->expects($this->once()) + ->method('execute_sql') + ->with($this->stringContains('UPDATE version SET')) + ->willReturn(false); + + $mockInstaller->expects($this->once()) + ->method('mysqliError') + ->with($mockMysqli) + ->willReturn('Mock SQL error'); + + $mockInstaller->expects($this->once()) + ->method('mysqliErrno') + ->with($mockMysqli) + ->willReturn(1062); + + $result = $mockInstaller->add_version_info(); + + $this->assertFalse($result); + $this->assertStringContainsString('ERROR. Unable insert version information into database', $mockInstaller->error_message); + $this->assertStringContainsString('Mock SQL error', $mockInstaller->error_message); + $this->assertStringContainsString('1062', $mockInstaller->error_message); + } + + public function testAddInitialUserSuccess(): void + { + $config = [ + 'igroup' => 'testgroup', + 'iuser' => 'testuser', + 'iuname' => 'TestLastName', + 'iufname' => 'TestFirstName', + 'iuserpass' => 'testpassword', + 'i2faenable' => false + ]; + + $mockInstaller = $this->createMockInstaller($config); + $mockInstaller->dbh = $this->createMock(mysqli::class); + + // Track expected SQL calls + $expectedSqlCalls = [ + "INSERT INTO `groups`", + "INSERT INTO users", + "INSERT INTO users_secure" + ]; + $callCount = 0; + + // Mock the three SQL executions for groups, users, and users_secure + $mockInstaller->expects($this->exactly(3)) + ->method('execute_sql') + ->willReturnCallback(function ($sql) use (&$expectedSqlCalls, &$callCount) { + $this->assertStringContainsString($expectedSqlCalls[$callCount], $sql); + $callCount++; + return true; + }); + + $mockInstaller->method('escapeSql') + ->willReturnArgument(0); + + $result = $mockInstaller->add_initial_user(); + + $this->assertTrue($result); + $this->assertEmpty($mockInstaller->error_message); + } + + public function testAddInitialUserGroupInsertFails(): void + { + $config = [ + 'igroup' => 'testgroup', + 'iuser' => 'testuser', + 'iuname' => 'TestLastName', + 'iufname' => 'TestFirstName', + 'iuserpass' => 'testpassword' + ]; + + $mockInstaller = $this->createMockInstaller($config); + $mockInstaller->dbh = $this->createMock(mysqli::class); + + // First SQL call (groups insert) fails + $mockInstaller->expects($this->once()) + ->method('execute_sql') + ->with($this->stringContains("INSERT INTO `groups`")) + ->willReturn(false); + + $mockInstaller->expects($this->once()) + ->method('mysqliError') + ->willReturn('Mock groups error'); + + $mockInstaller->expects($this->once()) + ->method('mysqliErrno') + ->willReturn(1062); + + $mockInstaller->method('escapeSql') + ->willReturnArgument(0); + + $result = $mockInstaller->add_initial_user(); + + $this->assertFalse($result); + $this->assertStringContainsString('ERROR. Unable to add initial user group', $mockInstaller->error_message); + $this->assertStringContainsString('Mock groups error', $mockInstaller->error_message); + $this->assertStringContainsString('1062', $mockInstaller->error_message); + } + + public function testAddInitialUserUserInsertFails(): void + { + $config = [ + 'igroup' => 'testgroup', + 'iuser' => 'testuser', + 'iuname' => 'TestLastName', + 'iufname' => 'TestFirstName', + 'iuserpass' => 'testpassword' + ]; + + $mockInstaller = $this->createMockInstaller($config); + $mockInstaller->dbh = $this->createMock(mysqli::class); + + // Track expected SQL calls + $expectedSqlCalls = [ + "INSERT INTO `groups`", + "INSERT INTO users" + ]; + $callCount = 0; + + // First SQL call succeeds, second fails + $mockInstaller->expects($this->exactly(2)) + ->method('execute_sql') + ->willReturnCallback(function ($sql) use (&$expectedSqlCalls, &$callCount) { + $this->assertStringContainsString($expectedSqlCalls[$callCount], $sql); + $callCount++; + return $callCount === 1 ? true : false; // First succeeds, second fails + }); + + $mockInstaller->expects($this->once()) + ->method('mysqliError') + ->willReturn('Mock user error'); + + $mockInstaller->expects($this->once()) + ->method('mysqliErrno') + ->willReturn(1062); + + $mockInstaller->method('escapeSql') + ->willReturnArgument(0); + + $result = $mockInstaller->add_initial_user(); + + $this->assertFalse($result); + $this->assertStringContainsString('ERROR. Unable to add initial user', $mockInstaller->error_message); + $this->assertStringContainsString('Mock user error', $mockInstaller->error_message); + $this->assertStringContainsString('1062', $mockInstaller->error_message); + } + + public function testAddInitialUserSecureInsertFails(): void + { + $config = [ + 'igroup' => 'testgroup', + 'iuser' => 'testuser', + 'iuname' => 'TestLastName', + 'iufname' => 'TestFirstName', + 'iuserpass' => 'testpassword' + ]; + + $mockInstaller = $this->createMockInstaller($config); + $mockInstaller->dbh = $this->createMock(mysqli::class); + + // Track expected SQL calls + $expectedSqlCalls = [ + "INSERT INTO `groups`", + "INSERT INTO users", + "INSERT INTO users_secure" + ]; + $callCount = 0; + + // First two SQL calls succeed, third fails + $mockInstaller->expects($this->exactly(3)) + ->method('execute_sql') + ->willReturnCallback(function ($sql) use (&$expectedSqlCalls, &$callCount) { + $this->assertStringContainsString($expectedSqlCalls[$callCount], $sql); + $callCount++; + return $callCount <= 2 ? true : false; // First two succeed, third fails + }); + + $mockInstaller->expects($this->once()) + ->method('mysqliError') + ->willReturn('Mock secure error'); + + $mockInstaller->expects($this->once()) + ->method('mysqliErrno') + ->willReturn(1062); + + $mockInstaller->method('escapeSql') + ->willReturnArgument(0); + + $result = $mockInstaller->add_initial_user(); + + $this->assertFalse($result); + $this->assertStringContainsString('ERROR. Unable to add initial user login credentials', $mockInstaller->error_message); + $this->assertStringContainsString('Mock secure error', $mockInstaller->error_message); + $this->assertStringContainsString('1062', $mockInstaller->error_message); + } + + public function testAddInitialUserWith2FASuccess(): void + { + $config = [ + 'igroup' => 'testgroup', + 'iuser' => 'testuser', + 'iuname' => 'TestLastName', + 'iufname' => 'TestFirstName', + 'iuserpass' => 'testpassword', + 'i2faenable' => true, + 'i2fasecret' => 'test2fasecret' + ]; + + $mockInstaller = $this->createMockInstaller($config); + $mockInstaller->dbh = $this->createMock(mysqli::class); + + // Mock class existence checks to return true + $mockInstaller->method('totpClassExists') + ->willReturn(true); + + $mockInstaller->method('cryptoGenClassExists') + ->willReturn(true); + + $mockInstaller->method('encryptTotpSecret') + ->willReturn('encrypted_secret'); + + // Mock execute_sql to succeed for all calls (including 2FA) + $mockInstaller->method('execute_sql') + ->willReturn(true); + + $mockInstaller->method('escapeSql') + ->willReturnArgument(0); + + $result = $mockInstaller->add_initial_user(); + + $this->assertTrue($result); + $this->assertEmpty($mockInstaller->error_message); + } + + public function testAddInitialUserWith2FAInsertFails(): void + { + $config = [ + 'igroup' => 'testgroup', + 'iuser' => 'testuser', + 'iuname' => 'TestLastName', + 'iufname' => 'TestFirstName', + 'iuserpass' => 'testpassword', + 'i2faenable' => true, + 'i2fasecret' => 'test2fasecret' + ]; + + $mockInstaller = $this->createMockInstaller($config); + $mockInstaller->dbh = $this->createMock(mysqli::class); + + // Mock class existence checks to return true + $mockInstaller->method('totpClassExists') + ->willReturn(true); + + $mockInstaller->method('cryptoGenClassExists') + ->willReturn(true); + + $mockInstaller->method('encryptTotpSecret') + ->willReturn('encrypted_secret'); + + // Mock execute_sql to succeed for non-2FA calls, fail for 2FA calls + $mockInstaller->method('execute_sql') + ->willReturnCallback(function ($sql) { + // Fail specifically on 2FA insert - return exactly false + if (stripos($sql, 'login_mfa_registrations') !== false) { + return false; + } + // Succeed on all other calls + return true; + }); + + $mockInstaller->method('mysqliError') + ->willReturn('Mock 2FA error'); + + $mockInstaller->method('mysqliErrno') + ->willReturn(1062); + + $mockInstaller->method('escapeSql') + ->willReturnArgument(0); + + $result = $mockInstaller->add_initial_user(); + + $this->assertFalse($result); + $this->assertStringContainsString("ERROR. Unable to add initial user's 2FA credentials", $mockInstaller->error_message); + } + + public function testAddInitialUserWith2FADisabled(): void + { + $config = [ + 'igroup' => 'testgroup', + 'iuser' => 'testuser', + 'iuname' => 'TestLastName', + 'iufname' => 'TestFirstName', + 'iuserpass' => 'testpassword', + 'i2faenable' => false, + 'i2fasecret' => 'test2fasecret' + ]; + + $mockInstaller = $this->createMockInstaller($config); + $mockInstaller->dbh = $this->createMock(mysqli::class); + + // Track expected SQL calls + $expectedSqlCalls = [ + "INSERT INTO `groups`", + "INSERT INTO users", + "INSERT INTO users_secure" + ]; + $callCount = 0; + + // Only three SQL executions (no 2FA insert) + $mockInstaller->expects($this->exactly(3)) + ->method('execute_sql') + ->willReturnCallback(function ($sql) use (&$expectedSqlCalls, &$callCount) { + $this->assertStringContainsString($expectedSqlCalls[$callCount], $sql); + $callCount++; + return true; + }); + + $mockInstaller->method('escapeSql') + ->willReturnArgument(0); + + $result = $mockInstaller->add_initial_user(); + + $this->assertTrue($result); + $this->assertEmpty($mockInstaller->error_message); + } + + public function testAddInitialUserWith2FANoSecret(): void + { + $config = [ + 'igroup' => 'testgroup', + 'iuser' => 'testuser', + 'iuname' => 'TestLastName', + 'iufname' => 'TestFirstName', + 'iuserpass' => 'testpassword', + 'i2faenable' => true, + 'i2fasecret' => '' + ]; + + $mockInstaller = $this->createMockInstaller($config); + $mockInstaller->dbh = $this->createMock(mysqli::class); + + // Track expected SQL calls + $expectedSqlCalls = [ + "INSERT INTO `groups`", + "INSERT INTO users", + "INSERT INTO users_secure" + ]; + $callCount = 0; + + // Only three SQL executions (no 2FA insert due to empty secret) + $mockInstaller->expects($this->exactly(3)) + ->method('execute_sql') + ->willReturnCallback(function ($sql) use (&$expectedSqlCalls, &$callCount) { + $this->assertStringContainsString($expectedSqlCalls[$callCount], $sql); + $callCount++; + return true; + }); + + $mockInstaller->method('escapeSql') + ->willReturnArgument(0); + + $result = $mockInstaller->add_initial_user(); + + $this->assertTrue($result); + $this->assertEmpty($mockInstaller->error_message); + } + + public function testAddInitialUserWith2FAClassesNotExist(): void + { + $config = [ + 'igroup' => 'testgroup', + 'iuser' => 'testuser', + 'iuname' => 'TestLastName', + 'iufname' => 'TestFirstName', + 'iuserpass' => 'testpassword', + 'i2faenable' => true, + 'i2fasecret' => 'test2fasecret' + ]; + + $mockInstaller = $this->createMockInstaller($config); + $mockInstaller->dbh = $this->createMock(mysqli::class); + + // Mock class existence checks to return false + $mockInstaller->method('totpClassExists') + ->willReturn(false); + + $mockInstaller->method('cryptoGenClassExists') + ->willReturn(false); + + $mockInstaller->method('encryptTotpSecret') + ->willReturn('encrypted_secret'); + + // Track expected SQL calls + $expectedSqlCalls = [ + "INSERT INTO `groups`", + "INSERT INTO users", + "INSERT INTO users_secure" + ]; + $callCount = 0; + + // Only three SQL executions (no 2FA insert due to missing classes) + $mockInstaller->expects($this->exactly(3)) + ->method('execute_sql') + ->willReturnCallback(function ($sql) use (&$expectedSqlCalls, &$callCount) { + $this->assertStringContainsString($expectedSqlCalls[$callCount], $sql); + $callCount++; + return true; + }); + + $mockInstaller->method('escapeSql') + ->willReturnArgument(0); + + $result = $mockInstaller->add_initial_user(); + + $this->assertTrue($result); + $this->assertEmpty($mockInstaller->error_message); + } + + public function testInstallAdditionalUsersSuccess(): void + { + $mockInstaller = $this->createMockInstaller(); + + $mockInstaller->expects($this->once()) + ->method('load_file') + ->with($mockInstaller->additional_users, 'Additional Official Users') + ->willReturn("Creating Additional Official Users tables...\nOK.
\n"); + + $result = $mockInstaller->install_additional_users(); + + $this->assertTrue($result); + } + + public function testInstallAdditionalUsersFailure(): void + { + $mockInstaller = $this->createMockInstaller(); + + $mockInstaller->expects($this->once()) + ->method('load_file') + ->with($mockInstaller->additional_users, 'Additional Official Users') + ->willReturn(false); + + $result = $mockInstaller->install_additional_users(); + + $this->assertFalse($result); + } + + public function testInstallAdditionalUsersWithCorrectFilePath(): void + { + $mockInstaller = $this->createMockInstaller(); + + // Verify the additional_users property contains the expected path + $expectedPath = __DIR__ . '/../../../../../sql/official_additional_users.sql'; + $this->assertEquals(realpath($expectedPath), realpath($mockInstaller->additional_users)); + + $mockInstaller->expects($this->once()) + ->method('load_file') + ->willReturn("Creating Additional Official Users tables...\nOK.
\n"); + + $result = $mockInstaller->install_additional_users(); + + $this->assertTrue($result); + } + + public function testInstallAdditionalUsersWithLoadFileReturnString(): void + { + $mockInstaller = $this->createMockInstaller(); + + $expectedReturnString = "Creating Additional Official Users tables...\nLoading official users...\nOK.
\n"; + + $mockInstaller->expects($this->once()) + ->method('load_file') + ->with($mockInstaller->additional_users, 'Additional Official Users') + ->willReturn($expectedReturnString); + + $result = $mockInstaller->install_additional_users(); + + $this->assertTrue($result); + } + + public function testOnCareCoordinationSuccess(): void + { + $mockInstaller = $this->createMockInstaller(); + + // Mock the database query results + $mockModuleResult = $this->createMock(mysqli_result::class); + $mockSectionResult = $this->createMock(mysqli_result::class); + $mockGroupResult = $this->createMock(mysqli_result::class); + + // Set up execute_sql expectations for the three SELECT queries and one INSERT + $callCount = 0; + $mockInstaller->expects($this->exactly(4)) + ->method('execute_sql') + ->willReturnCallback(function ($sql) use (&$callCount, $mockModuleResult, $mockSectionResult, $mockGroupResult) { + $callCount++; + switch ($callCount) { + case 1: + $this->assertStringContainsString("SELECT `mod_id` FROM `modules`", $sql); + return $mockModuleResult; + case 2: + $this->assertStringContainsString("SELECT `section_id` FROM `module_acl_sections`", $sql); + return $mockSectionResult; + case 3: + $this->assertStringContainsString("SELECT `id` FROM `gacl_aro_groups`", $sql); + return $mockGroupResult; + case 4: + $this->assertStringContainsString("INSERT INTO `module_acl_group_settings`", $sql); + return true; + default: + return false; + } + }); + + // Mock mysqliFetchArray to return the expected data + $mockInstaller->expects($this->exactly(3)) + ->method('mysqliFetchArray') + ->willReturnOnConsecutiveCalls( + ['mod_id' => '123'], + ['section_id' => '456'], + ['id' => '789'] + ); + + // Mock escapeSql to return the input unchanged for testing + $mockInstaller->method('escapeSql') + ->willReturnArgument(0); + + $result = $mockInstaller->on_care_coordination(); + + $this->assertTrue($result); + $this->assertEmpty($mockInstaller->error_message); + } + + public function testOnCareCoordinationFailsWhenModuleNotFound(): void + { + $mockInstaller = $this->createMockInstaller(); + + $mockModuleResult = $this->createMock(mysqli_result::class); + + $mockInstaller->expects($this->once()) + ->method('execute_sql') + ->with($this->stringContains("SELECT `mod_id` FROM `modules`")) + ->willReturn($mockModuleResult); + + // Return empty mod_id to simulate module not found + $mockInstaller->expects($this->once()) + ->method('mysqliFetchArray') + ->with($mockModuleResult, MYSQLI_ASSOC) + ->willReturn(['mod_id' => '']); + + $result = $mockInstaller->on_care_coordination(); + + $this->assertFalse($result); + $this->assertStringContainsString('ERROR configuring Care Coordination module. Unable to get mod_id for Carecoordination module', $mockInstaller->error_message); + } + + public function testOnCareCoordinationFailsWhenSectionNotFound(): void + { + $mockInstaller = $this->createMockInstaller(); + + $mockModuleResult = $this->createMock(mysqli_result::class); + $mockSectionResult = $this->createMock(mysqli_result::class); + + $callCount = 0; + $mockInstaller->expects($this->exactly(2)) + ->method('execute_sql') + ->willReturnCallback(function ($sql) use (&$callCount, $mockModuleResult, $mockSectionResult) { + $callCount++; + if ($callCount === 1) { + $this->assertStringContainsString("SELECT `mod_id` FROM `modules`", $sql); + return $mockModuleResult; + } else { + $this->assertStringContainsString("SELECT `section_id` FROM `module_acl_sections`", $sql); + return $mockSectionResult; + } + }); + + // First call returns valid mod_id, second call returns empty section_id + $mockInstaller->expects($this->exactly(2)) + ->method('mysqliFetchArray') + ->willReturnOnConsecutiveCalls( + ['mod_id' => '123'], + ['section_id' => ''] + ); + + $result = $mockInstaller->on_care_coordination(); + + $this->assertFalse($result); + $this->assertStringContainsString('ERROR configuring Care Coordination module. Unable to get section_id for carecoordination module section', $mockInstaller->error_message); + } + + public function testOnCareCoordinationFailsWhenGroupNotFound(): void + { + $mockInstaller = $this->createMockInstaller(); + + $mockModuleResult = $this->createMock(mysqli_result::class); + $mockSectionResult = $this->createMock(mysqli_result::class); + $mockGroupResult = $this->createMock(mysqli_result::class); + + $callCount = 0; + $mockInstaller->expects($this->exactly(3)) + ->method('execute_sql') + ->willReturnCallback(function ($sql) use (&$callCount, $mockModuleResult, $mockSectionResult, $mockGroupResult) { + $callCount++; + switch ($callCount) { + case 1: + $this->assertStringContainsString("SELECT `mod_id` FROM `modules`", $sql); + return $mockModuleResult; + case 2: + $this->assertStringContainsString("SELECT `section_id` FROM `module_acl_sections`", $sql); + return $mockSectionResult; + case 3: + $this->assertStringContainsString("SELECT `id` FROM `gacl_aro_groups`", $sql); + return $mockGroupResult; + default: + return false; + } + }); + + // First two calls succeed, third returns empty group id + $mockInstaller->expects($this->exactly(3)) + ->method('mysqliFetchArray') + ->willReturnOnConsecutiveCalls( + ['mod_id' => '123'], + ['section_id' => '456'], + ['id' => ''] + ); + + $result = $mockInstaller->on_care_coordination(); + + $this->assertFalse($result); + $this->assertStringContainsString('ERROR configuring Care Coordination module. Unable to get id for gacl_aro_groups admin section', $mockInstaller->error_message); + } + + public function testOnCareCoordinationFailsWhenInsertFails(): void + { + $mockInstaller = $this->createMockInstaller(); + + $mockModuleResult = $this->createMock(mysqli_result::class); + $mockSectionResult = $this->createMock(mysqli_result::class); + $mockGroupResult = $this->createMock(mysqli_result::class); + + $callCount = 0; + $mockInstaller->expects($this->exactly(4)) + ->method('execute_sql') + ->willReturnCallback(function ($sql) use (&$callCount, $mockModuleResult, $mockSectionResult, $mockGroupResult) { + $callCount++; + switch ($callCount) { + case 1: + $this->assertStringContainsString("SELECT `mod_id` FROM `modules`", $sql); + return $mockModuleResult; + case 2: + $this->assertStringContainsString("SELECT `section_id` FROM `module_acl_sections`", $sql); + return $mockSectionResult; + case 3: + $this->assertStringContainsString("SELECT `id` FROM `gacl_aro_groups`", $sql); + return $mockGroupResult; + case 4: + $this->assertStringContainsString("INSERT INTO `module_acl_group_settings`", $sql); + return false; // Insert fails + default: + return false; + } + }); + + // All SELECT queries succeed + $mockInstaller->expects($this->exactly(3)) + ->method('mysqliFetchArray') + ->willReturnOnConsecutiveCalls( + ['mod_id' => '123'], + ['section_id' => '456'], + ['id' => '789'] + ); + + $mockInstaller->method('escapeSql') + ->willReturnArgument(0); + + $result = $mockInstaller->on_care_coordination(); + + $this->assertFalse($result); + $this->assertStringContainsString('ERROR configuring Care Coordination module. Unable to add the module_acl_group_settings acl entry', $mockInstaller->error_message); + } + + public function testOnCareCoordinationWithCorrectSqlQueries(): void + { + $mockInstaller = $this->createMockInstaller(); + + $mockModuleResult = $this->createMock(mysqli_result::class); + $mockSectionResult = $this->createMock(mysqli_result::class); + $mockGroupResult = $this->createMock(mysqli_result::class); + + $expectedQueries = [ + "SELECT `mod_id` FROM `modules` WHERE `mod_name` = 'Carecoordination' LIMIT 1", + "SELECT `section_id` FROM `module_acl_sections` WHERE `section_identifier` = 'carecoordination' LIMIT 1", + "SELECT `id` FROM `gacl_aro_groups` WHERE `value` = 'admin' LIMIT 1" + ]; + + $callCount = 0; + $mockInstaller->expects($this->exactly(4)) + ->method('execute_sql') + ->willReturnCallback(function ($sql) use (&$callCount, $mockModuleResult, $mockSectionResult, $mockGroupResult, $expectedQueries) { + $callCount++; + switch ($callCount) { + case 1: + case 2: + case 3: + $this->assertEquals($expectedQueries[$callCount - 1], $sql); + return [$mockModuleResult, $mockSectionResult, $mockGroupResult][$callCount - 1]; + case 4: + $this->assertStringContainsString("INSERT INTO `module_acl_group_settings`", $sql); + $this->assertStringContainsString("'123'", $sql); + $this->assertStringContainsString("'789'", $sql); + $this->assertStringContainsString("'456'", $sql); + $this->assertStringContainsString("1", $sql); + return true; + default: + return false; + } + }); + + $mockInstaller->expects($this->exactly(3)) + ->method('mysqliFetchArray') + ->willReturnOnConsecutiveCalls( + ['mod_id' => '123'], + ['section_id' => '456'], + ['id' => '789'] + ); + + $mockInstaller->method('escapeSql') + ->willReturnArgument(0); + + $result = $mockInstaller->on_care_coordination(); + + $this->assertTrue($result); + } + + public function testGetInitialUserMfaTotpSuccess(): void + { + $config = [ + 'i2faenable' => true, + 'i2fasecret' => 'test2fasecret', + 'iuser' => 'testuser' + ]; + + $mockInstaller = $this->createMockInstaller($config); + + $mockTotp = $this->createMock(Totp::class); + + $mockInstaller->expects($this->once()) + ->method('totpClassExists') + ->willReturn(true); + + $mockInstaller->expects($this->once()) + ->method('createTotpInstance') + ->with('test2fasecret', 'testuser') + ->willReturn($mockTotp); + + $result = $mockInstaller->get_initial_user_mfa_totp(); + + $this->assertSame($mockTotp, $result); + } + + public function testGetInitialUserMfaTotpWhen2faDisabled(): void + { + $config = [ + 'i2faenable' => false, + 'i2fasecret' => 'test2fasecret', + 'iuser' => 'testuser' + ]; + + $mockInstaller = $this->createMockInstaller($config); + + // These methods should not be called when 2FA is disabled + $mockInstaller->expects($this->never()) + ->method('totpClassExists'); + + $mockInstaller->expects($this->never()) + ->method('createTotpInstance'); + + $result = $mockInstaller->get_initial_user_mfa_totp(); + + $this->assertFalse($result); + } + + public function testGetInitialUserMfaTotpWithEmptySecret(): void + { + $config = [ + 'i2faenable' => true, + 'i2fasecret' => '', + 'iuser' => 'testuser' + ]; + + $mockInstaller = $this->createMockInstaller($config); + + // These methods should not be called when secret is empty + $mockInstaller->expects($this->never()) + ->method('totpClassExists'); + + $mockInstaller->expects($this->never()) + ->method('createTotpInstance'); + + $result = $mockInstaller->get_initial_user_mfa_totp(); + + $this->assertFalse($result); + } + + public function testGetInitialUserMfaTotpWhenTotpClassDoesNotExist(): void + { + $config = [ + 'i2faenable' => true, + 'i2fasecret' => 'test2fasecret', + 'iuser' => 'testuser' + ]; + + $mockInstaller = $this->createMockInstaller($config); + + $mockInstaller->expects($this->once()) + ->method('totpClassExists') + ->willReturn(false); + + // createTotpInstance should not be called when Totp class doesn't exist + $mockInstaller->expects($this->never()) + ->method('createTotpInstance'); + + $result = $mockInstaller->get_initial_user_mfa_totp(); + + $this->assertFalse($result); + } + + public function testGetInitialUserMfaTotpWithTruthyString2faEnable(): void + { + $config = [ + 'i2faenable' => 'true', // String 'true' should be truthy + 'i2fasecret' => 'test2fasecret', + 'iuser' => 'testuser' + ]; + + $mockInstaller = $this->createMockInstaller($config); + + $mockTotp = $this->createMock(Totp::class); + + $mockInstaller->expects($this->once()) + ->method('totpClassExists') + ->willReturn(true); + + $mockInstaller->expects($this->once()) + ->method('createTotpInstance') + ->with('test2fasecret', 'testuser') + ->willReturn($mockTotp); + + $result = $mockInstaller->get_initial_user_mfa_totp(); + + $this->assertSame($mockTotp, $result); + } + + public function testGetInitialUserMfaTotpWithFalsyString2faEnable(): void + { + $config = [ + 'i2faenable' => '0', // String '0' should be falsy + 'i2fasecret' => 'test2fasecret', + 'iuser' => 'testuser' + ]; + + $mockInstaller = $this->createMockInstaller($config); + + $mockInstaller->expects($this->never()) + ->method('totpClassExists'); + + $mockInstaller->expects($this->never()) + ->method('createTotpInstance'); + + $result = $mockInstaller->get_initial_user_mfa_totp(); + + $this->assertFalse($result); + } + + public function testGetInitialUserMfaTotpWithNullSecret(): void + { + $config = [ + 'i2faenable' => true, + 'i2fasecret' => null, + 'iuser' => 'testuser' + ]; + + $mockInstaller = $this->createMockInstaller($config); + + $mockInstaller->expects($this->never()) + ->method('totpClassExists'); + + $mockInstaller->expects($this->never()) + ->method('createTotpInstance'); + + $result = $mockInstaller->get_initial_user_mfa_totp(); + + $this->assertFalse($result); + } + + public function testGetInitialUserMfaTotpParameterPassing(): void + { + $config = [ + 'i2faenable' => true, + 'i2fasecret' => 'my_secret_key_123', + 'iuser' => 'admin_user' + ]; + + $mockInstaller = $this->createMockInstaller($config); + + $mockTotp = $this->createMock(Totp::class); + + $mockInstaller->expects($this->once()) + ->method('totpClassExists') + ->willReturn(true); + + // Verify exact parameters are passed to createTotpInstance + $mockInstaller->expects($this->once()) + ->method('createTotpInstance') + ->with( + $this->equalTo('my_secret_key_123'), + $this->equalTo('admin_user') + ) + ->willReturn($mockTotp); + + $result = $mockInstaller->get_initial_user_mfa_totp(); + + $this->assertSame($mockTotp, $result); + } + + public function testGetInitialUserMfaTotpAllConditionsMustBeTrue(): void + { + // Test that ALL conditions must be satisfied for success + $scenarios = [ + // 2FA disabled, has secret, class exists + [ + 'config' => ['i2faenable' => false, 'i2fasecret' => 'secret', 'iuser' => 'user'], + 'totpClassExists' => true, + 'expectedTotpClassCalls' => 0, + 'expectedCreateTotpCalls' => 0 + ], + // 2FA enabled, no secret, class exists + [ + 'config' => ['i2faenable' => true, 'i2fasecret' => '', 'iuser' => 'user'], + 'totpClassExists' => true, + 'expectedTotpClassCalls' => 0, + 'expectedCreateTotpCalls' => 0 + ], + // 2FA enabled, has secret, no class + [ + 'config' => ['i2faenable' => true, 'i2fasecret' => 'secret', 'iuser' => 'user'], + 'totpClassExists' => false, + 'expectedTotpClassCalls' => 1, + 'expectedCreateTotpCalls' => 0 + ] + ]; + + foreach ($scenarios as $scenario) { + $mockInstaller = $this->createMockInstaller($scenario['config']); + + if ($scenario['expectedTotpClassCalls'] > 0) { + $mockInstaller->expects($this->exactly($scenario['expectedTotpClassCalls'])) + ->method('totpClassExists') + ->willReturn($scenario['totpClassExists']); + } else { + $mockInstaller->expects($this->never()) + ->method('totpClassExists'); + } + + $mockInstaller->expects($this->exactly($scenario['expectedCreateTotpCalls'])) + ->method('createTotpInstance'); + + $result = $mockInstaller->get_initial_user_mfa_totp(); + + $this->assertFalse($result, 'Expected false for scenario: ' . json_encode($scenario['config'])); + } + } + + public function testCreateSiteDirectoryWhenDirectoryAlreadyExists(): void + { + $mockInstaller = $this->createMockInstaller(['source_site_id' => 'default']); + + // Set up globals + $GLOBALS['OE_SITE_DIR'] = '/path/to/existing/site'; + $GLOBALS['OE_SITES_BASE'] = '/path/to/sites'; + + // Directory already exists + $mockInstaller->expects($this->once()) + ->method('fileExists') + ->with('/path/to/existing/site') + ->willReturn(true); + + // These methods should not be called when directory exists + $mockInstaller->expects($this->never()) + ->method('recurse_copy'); + + $mockInstaller->expects($this->never()) + ->method('globPattern'); + + $mockInstaller->expects($this->never()) + ->method('unlinkFile'); + + $result = $mockInstaller->create_site_directory(); + + $this->assertTrue($result); + } + + public function testCreateSiteDirectorySuccess(): void + { + $config = [ + 'source_site_id' => 'source_site', + 'clone_database' => '' + ]; + $mockInstaller = $this->createMockInstaller($config); + + // Set up globals + $GLOBALS['OE_SITE_DIR'] = '/path/to/new/site'; + $GLOBALS['OE_SITES_BASE'] = '/path/to/sites'; + + // Directory does not exist + $mockInstaller->expects($this->once()) + ->method('fileExists') + ->with('/path/to/new/site') + ->willReturn(false); + + // Copy succeeds + $mockInstaller->expects($this->once()) + ->method('recurse_copy') + ->with('/path/to/sites/source_site', '/path/to/new/site') + ->willReturn(true); + + // Not cloning database, so files should be deleted + $mockFiles = [ + '/path/to/new/site/documents/logs_and_misc/methods/file1.key', + '/path/to/new/site/documents/logs_and_misc/methods/file2.cert' + ]; + + $mockInstaller->expects($this->once()) + ->method('globPattern') + ->with('/path/to/new/site/documents/logs_and_misc/methods/*') + ->willReturn($mockFiles); + + $mockInstaller->expects($this->exactly(2)) + ->method('unlinkFile') + ->willReturnCallback(function ($file) use ($mockFiles) { + $this->assertContains($file, $mockFiles); + return true; + }); + + $result = $mockInstaller->create_site_directory(); + + $this->assertTrue($result); + } + + public function testCreateSiteDirectorySuccessWithCloneDatabase(): void + { + $config = [ + 'source_site_id' => 'source_site', + 'clone_database' => 'true' + ]; + $mockInstaller = $this->createMockInstaller($config); + + // Set up globals + $GLOBALS['OE_SITE_DIR'] = '/path/to/new/site'; + $GLOBALS['OE_SITES_BASE'] = '/path/to/sites'; + + // Directory does not exist + $mockInstaller->expects($this->once()) + ->method('fileExists') + ->with('/path/to/new/site') + ->willReturn(false); + + // Copy succeeds + $mockInstaller->expects($this->once()) + ->method('recurse_copy') + ->with('/path/to/sites/source_site', '/path/to/new/site') + ->willReturn(true); + + // Cloning database, so files should NOT be deleted + $mockInstaller->expects($this->never()) + ->method('globPattern'); + + $mockInstaller->expects($this->never()) + ->method('unlinkFile'); + + $result = $mockInstaller->create_site_directory(); + + $this->assertTrue($result); + } + + public function testCreateSiteDirectoryFailsWhenCopyFails(): void + { + $config = [ + 'source_site_id' => 'source_site', + 'clone_database' => '' + ]; + $mockInstaller = $this->createMockInstaller($config); + + // Set up globals + $GLOBALS['OE_SITE_DIR'] = '/path/to/new/site'; + $GLOBALS['OE_SITES_BASE'] = '/path/to/sites'; + + // Directory does not exist + $mockInstaller->expects($this->once()) + ->method('fileExists') + ->with('/path/to/new/site') + ->willReturn(false); + + // Copy fails + $mockInstaller->expects($this->once()) + ->method('recurse_copy') + ->with('/path/to/sites/source_site', '/path/to/new/site') + ->willReturn(false); + + // Set error message in the mock (simulating recurse_copy failure) + $mockInstaller->error_message = 'Copy failed'; + + // These methods should not be called when copy fails + $mockInstaller->expects($this->never()) + ->method('globPattern'); + + $mockInstaller->expects($this->never()) + ->method('unlinkFile'); + + $result = $mockInstaller->create_site_directory(); + + $this->assertFalse($result); + $this->assertStringContainsString("unable to copy directory: '/path/to/sites/source_site' to '/path/to/new/site'. Copy failed", $mockInstaller->error_message); + } + + public function testCreateSiteDirectoryWithEmptyGlobResult(): void + { + $config = [ + 'source_site_id' => 'source_site', + 'clone_database' => '' + ]; + $mockInstaller = $this->createMockInstaller($config); + + // Set up globals + $GLOBALS['OE_SITE_DIR'] = '/path/to/new/site'; + $GLOBALS['OE_SITES_BASE'] = '/path/to/sites'; + + // Directory does not exist + $mockInstaller->expects($this->once()) + ->method('fileExists') + ->with('/path/to/new/site') + ->willReturn(false); + + // Copy succeeds + $mockInstaller->expects($this->once()) + ->method('recurse_copy') + ->with('/path/to/sites/source_site', '/path/to/new/site') + ->willReturn(true); + + // No files to delete + $mockInstaller->expects($this->once()) + ->method('globPattern') + ->with('/path/to/new/site/documents/logs_and_misc/methods/*') + ->willReturn([]); + + // unlinkFile should not be called when no files exist + $mockInstaller->expects($this->never()) + ->method('unlinkFile'); + + $result = $mockInstaller->create_site_directory(); + + $this->assertTrue($result); + } + + public function testCreateSiteDirectoryWithGlobReturnsFalse(): void + { + $config = [ + 'source_site_id' => 'source_site', + 'clone_database' => '' + ]; + $mockInstaller = $this->createMockInstaller($config); + + // Set up globals + $GLOBALS['OE_SITE_DIR'] = '/path/to/new/site'; + $GLOBALS['OE_SITES_BASE'] = '/path/to/sites'; + + // Directory does not exist + $mockInstaller->expects($this->once()) + ->method('fileExists') + ->with('/path/to/new/site') + ->willReturn(false); + + // Copy succeeds + $mockInstaller->expects($this->once()) + ->method('recurse_copy') + ->with('/path/to/sites/source_site', '/path/to/new/site') + ->willReturn(true); + + // glob() returns false (error case) + $mockInstaller->expects($this->once()) + ->method('globPattern') + ->with('/path/to/new/site/documents/logs_and_misc/methods/*') + ->willReturn(false); + + // unlinkFile should not be called when glob returns false + $mockInstaller->expects($this->never()) + ->method('unlinkFile'); + + $result = $mockInstaller->create_site_directory(); + + $this->assertTrue($result); + } + + public function testCreateSiteDirectoryUsesCorrectPaths(): void + { + $config = [ + 'source_site_id' => 'test_source', + 'clone_database' => '' + ]; + $mockInstaller = $this->createMockInstaller($config); + + // Set up specific globals to test path construction + $GLOBALS['OE_SITE_DIR'] = '/custom/site/path'; + $GLOBALS['OE_SITES_BASE'] = '/custom/sites/base'; + + $mockInstaller->expects($this->once()) + ->method('fileExists') + ->with('/custom/site/path') + ->willReturn(false); + + // Verify exact source and destination paths + $mockInstaller->expects($this->once()) + ->method('recurse_copy') + ->with('/custom/sites/base/test_source', '/custom/site/path') + ->willReturn(true); + + $mockInstaller->expects($this->once()) + ->method('globPattern') + ->with('/custom/site/path/documents/logs_and_misc/methods/*') + ->willReturn([]); + + $result = $mockInstaller->create_site_directory(); + + $this->assertTrue($result); + } + + public function testCreateSiteDirectoryWithVariousCloneDatabaseValues(): void + { + $scenarios = [ + // These values should be considered "truthy" and skip file deletion + ['clone_database' => 'true', 'shouldDeleteFiles' => false], + ['clone_database' => '1', 'shouldDeleteFiles' => false], + ['clone_database' => 'yes', 'shouldDeleteFiles' => false], + ['clone_database' => 'anything_non_empty', 'shouldDeleteFiles' => false], + // These values should be considered "falsy" and allow file deletion + ['clone_database' => '', 'shouldDeleteFiles' => true], + ['clone_database' => '0', 'shouldDeleteFiles' => true], + ['clone_database' => false, 'shouldDeleteFiles' => true], + ['clone_database' => null, 'shouldDeleteFiles' => true] + ]; + + foreach ($scenarios as $scenario) { + $config = [ + 'source_site_id' => 'source', + 'clone_database' => $scenario['clone_database'] + ]; + $mockInstaller = $this->createMockInstaller($config); + + // Set up globals + $GLOBALS['OE_SITE_DIR'] = '/test/site'; + $GLOBALS['OE_SITES_BASE'] = '/test/sites'; + + $mockInstaller->method('fileExists')->willReturn(false); + $mockInstaller->method('recurse_copy')->willReturn(true); + + if ($scenario['shouldDeleteFiles']) { + $mockInstaller->expects($this->once()) + ->method('globPattern') + ->willReturn([]); + } else { + $mockInstaller->expects($this->never()) + ->method('globPattern'); + } + + $result = $mockInstaller->create_site_directory(); + + $this->assertTrue($result, 'Failed for clone_database value: ' . json_encode($scenario['clone_database'])); + } + } + + public function testWriteConfigurationFileSuccess(): void + { + $config = [ + 'server' => 'test.server.com', + 'port' => '3307', + 'login' => 'test_user', + 'pass' => 'test_pass', + 'dbname' => 'test_db' + ]; + $mockInstaller = $this->createMockInstaller($config); + + // Set up globals and conffile + $GLOBALS['OE_SITE_DIR'] = '/test/site/dir'; + $mockInstaller->conffile = '/test/site/dir/sqlconf.php'; + + // Directory already exists + $mockInstaller->expects($this->once()) + ->method('fileExists') + ->with('/test/site/dir') + ->willReturn(true); + + // create_site_directory should not be called when directory exists (no mocking needed) + + // touchFile should be called + $mockInstaller->expects($this->once()) + ->method('touchFile') + ->with('/test/site/dir/sqlconf.php') + ->willReturn(true); + + // openFile should succeed + $mockFileHandle = fopen('php://memory', 'w+'); + $mockInstaller->expects($this->once()) + ->method('openFile') + ->with('/test/site/dir/sqlconf.php', 'w') + ->willReturn($mockFileHandle); + + // All writeToFile calls should succeed + $mockInstaller->expects($this->exactly(10)) + ->method('writeToFile') + ->willReturn(10); // Simulate successful writes + + // closeFile should succeed + $mockInstaller->expects($this->once()) + ->method('closeFile') + ->with($mockFileHandle) + ->willReturn(true); + + $result = $mockInstaller->write_configuration_file(); + + $this->assertTrue($result); + $this->assertEmpty($mockInstaller->error_message); + + fclose($mockFileHandle); + } + + public function testWriteConfigurationFileCreatesDirectoryWhenNotExists(): void + { + $mockInstaller = $this->createMockInstaller(); + + // Set up globals and conffile + $GLOBALS['OE_SITE_DIR'] = '/test/new/site/dir'; + $mockInstaller->conffile = '/test/new/site/dir/sqlconf.php'; + + // Directory does not exist + $mockInstaller->method('fileExists') + ->with('/test/new/site/dir') + ->willReturn(false); + + // create_site_directory should be called (no mocking needed - it will call the real method) + + // Mock successful file operations + $mockInstaller->method('touchFile')->willReturn(true); + $mockFileHandle = fopen('php://memory', 'w+'); + $mockInstaller->method('openFile')->willReturn($mockFileHandle); + $mockInstaller->method('writeToFile')->willReturn(10); + $mockInstaller->method('closeFile')->willReturn(true); + + $result = $mockInstaller->write_configuration_file(); + + $this->assertTrue($result); + + fclose($mockFileHandle); + } + + public function testWriteConfigurationFileFailsWhenOpenFileFails(): void + { + $mockInstaller = $this->createMockInstaller(); + + // Set up globals and conffile + $GLOBALS['OE_SITE_DIR'] = '/test/site/dir'; + $mockInstaller->conffile = '/test/site/dir/sqlconf.php'; + + // Directory exists + $mockInstaller->method('fileExists')->willReturn(true); + $mockInstaller->method('touchFile')->willReturn(true); + + // openFile fails + $mockInstaller->expects($this->once()) + ->method('openFile') + ->with('/test/site/dir/sqlconf.php', 'w') + ->willReturn(false); + + // writeToFile should not be called when file opening fails + $mockInstaller->expects($this->never()) + ->method('writeToFile'); + + $result = $mockInstaller->write_configuration_file(); + + $this->assertFalse($result); + $this->assertEquals('unable to open configuration file for writing: /test/site/dir/sqlconf.php', $mockInstaller->error_message); + } + + public function testWriteConfigurationFileFailsWhenWritesFail(): void + { + $mockInstaller = $this->createMockInstaller(); + + // Set up globals and conffile + $GLOBALS['OE_SITE_DIR'] = '/test/site/dir'; + $mockInstaller->conffile = '/test/site/dir/sqlconf.php'; + + $mockInstaller->method('fileExists')->willReturn(true); + $mockInstaller->method('touchFile')->willReturn(true); + + $mockFileHandle = fopen('php://memory', 'w+'); + $mockInstaller->method('openFile')->willReturn($mockFileHandle); + + // Simulate some write failures - return false for some calls + $writeCallCount = 0; + $mockInstaller->expects($this->exactly(10)) + ->method('writeToFile') + ->willReturnCallback(function () use (&$writeCallCount) { + $writeCallCount++; + // Fail on calls 3 and 7 to simulate partial write failures + return ($writeCallCount === 3 || $writeCallCount === 7) ? false : 10; + }); + + $mockInstaller->method('closeFile')->willReturn(false); // Also fail closeFile + + $result = $mockInstaller->write_configuration_file(); + + $this->assertFalse($result); + // Should report 3 failed operations (2 writeToFile + 1 closeFile) + $this->assertStringContainsString("ERROR. Couldn't write 3 lines to config file '/test/site/dir/sqlconf.php'", $mockInstaller->error_message); + + fclose($mockFileHandle); + } + + public function testWriteConfigurationFileGeneratesCorrectContent(): void + { + $config = [ + 'server' => 'db.example.com', + 'port' => '3308', + 'login' => 'dbuser', + 'pass' => 'dbpass', + 'dbname' => 'mydb' + ]; + $mockInstaller = $this->createMockInstaller($config); + + // Set up globals and conffile + $GLOBALS['OE_SITE_DIR'] = '/test/site/dir'; + $mockInstaller->conffile = '/test/site/dir/sqlconf.php'; + + $mockInstaller->method('fileExists')->willReturn(true); + $mockInstaller->method('touchFile')->willReturn(true); + + $mockFileHandle = fopen('php://memory', 'w+'); + $mockInstaller->method('openFile')->willReturn($mockFileHandle); + + // Capture the content being written + $writtenContent = []; + $mockInstaller->expects($this->exactly(10)) + ->method('writeToFile') + ->willReturnCallback(function ($handle, $data) use (&$writtenContent) { + $writtenContent[] = $data; + return strlen($data); + }); + + $mockInstaller->method('closeFile')->willReturn(true); + + $result = $mockInstaller->write_configuration_file(); + + $this->assertTrue($result); + + // Verify the content contains the expected configuration values + $allContent = implode('', $writtenContent); + $this->assertStringContainsString('db.example.com', $allContent); + $this->assertStringContainsString('3308', $allContent); + $this->assertStringContainsString('dbuser', $allContent); + $this->assertStringContainsString('dbpass', $allContent); + $this->assertStringContainsString('mydb', $allContent); + $this->assertStringContainsString('utf8mb4', $allContent); + $this->assertStringContainsString('$config = 1', $allContent); + + fclose($mockFileHandle); + } + + public function testWriteConfigurationFileHandlesSpecialCharactersInConfig(): void + { + $config = [ + 'server' => 'server-with-dashes.com', + 'login' => 'user_with_underscores', + 'pass' => 'p@ssw0rd!#$%', + 'dbname' => 'db-name_123' + ]; + $mockInstaller = $this->createMockInstaller($config); + + $GLOBALS['OE_SITE_DIR'] = '/test/site/dir'; + $mockInstaller->conffile = '/test/site/dir/sqlconf.php'; + + $mockInstaller->method('fileExists')->willReturn(true); + $mockInstaller->method('touchFile')->willReturn(true); + + $mockFileHandle = fopen('php://memory', 'w+'); + $mockInstaller->method('openFile')->willReturn($mockFileHandle); + + $writtenContent = []; + $mockInstaller->method('writeToFile') + ->willReturnCallback(function ($handle, $data) use (&$writtenContent) { + $writtenContent[] = $data; + return strlen($data); + }); + + $mockInstaller->method('closeFile')->willReturn(true); + + $result = $mockInstaller->write_configuration_file(); + + $this->assertTrue($result); + + // Verify special characters are handled properly + $allContent = implode('', $writtenContent); + $this->assertStringContainsString('server-with-dashes.com', $allContent); + $this->assertStringContainsString('user_with_underscores', $allContent); + $this->assertStringContainsString('p@ssw0rd!#$%', $allContent); + $this->assertStringContainsString('db-name_123', $allContent); + + fclose($mockFileHandle); + } + + public function testWriteConfigurationFileCountsErrorsCorrectly(): void + { + $mockInstaller = $this->createMockInstaller(); + + $GLOBALS['OE_SITE_DIR'] = '/test/site/dir'; + $mockInstaller->conffile = '/test/site/dir/sqlconf.php'; + + $mockInstaller->method('fileExists')->willReturn(true); + $mockInstaller->method('touchFile')->willReturn(true); + + $mockFileHandle = fopen('php://memory', 'w+'); + $mockInstaller->method('openFile')->willReturn($mockFileHandle); + + // Simulate exactly 5 write failures + $writeCallCount = 0; + $mockInstaller->method('writeToFile') + ->willReturnCallback(function () use (&$writeCallCount) { + $writeCallCount++; + // Fail on calls 2, 4, 6, 8, 10 + return ($writeCallCount % 2 === 0) ? false : 10; + }); + + $mockInstaller->method('closeFile')->willReturn(true); + + $result = $mockInstaller->write_configuration_file(); + + $this->assertFalse($result); + $this->assertStringContainsString("ERROR. Couldn't write 5 lines to config file", $mockInstaller->error_message); + + fclose($mockFileHandle); + } + + public function testInstallGaclSuccess(): void + { + $config = [ + 'iuser' => 'admin_user', + 'iuname' => 'Administrator Name' + ]; + $mockInstaller = $this->createMockInstaller($config); + + // Create a mock GACL API + $mockGacl = $this->createMock(\OpenEMR\Gacl\GaclApi::class); + + // Mock the newGaclApi method to return our mock + $mockInstaller->expects($this->once()) + ->method('newGaclApi') + ->willReturn($mockGacl); + + // Mock all add_object_section calls - first one has error checking, others don't + $mockGacl->method('add_object_section') + ->willReturn(true); + + // Mock all other GACL method calls that will be made + $mockGacl->method('add_object')->willReturn(true); + $mockGacl->method('add_group')->willReturn(1); + $mockGacl->method('add_group_object')->willReturn(true); + $mockGacl->method('add_acl')->willReturn(true); + + // The method should succeed and return true + $result = $mockInstaller->install_gacl(); + + $this->assertTrue($result); + $this->assertEmpty($mockInstaller->error_message); + } + + public function testInstallGaclFailsOnFirstAddObjectSection(): void + { + $config = [ + 'iuser' => 'admin_user', + 'iuname' => 'Administrator Name' + ]; + $mockInstaller = $this->createMockInstaller($config); + + // Create a mock GACL API + $mockGacl = $this->createMock(\OpenEMR\Gacl\GaclApi::class); + + $mockInstaller->expects($this->once()) + ->method('newGaclApi') + ->willReturn($mockGacl); + + // Mock add_object_section calls - first one fails (which should stop execution) + $callCount = 0; + $mockGacl->method('add_object_section') + ->willReturnCallback(function ($name, $identifier) use (&$callCount) { + $callCount++; + if ($callCount === 1) { + // First call (Accounting) should fail + $this->assertEquals('Accounting', $name); + $this->assertEquals('acct', $identifier); + return false; + } + // Subsequent calls should not happen due to early return + return true; + }); + + $result = $mockInstaller->install_gacl(); + + $this->assertFalse($result); + $this->assertEquals("ERROR, Unable to create the access controls for OpenEMR.", $mockInstaller->error_message); + } + + public function testInstallGaclUsesCorrectUserInfo(): void + { + $config = [ + 'iuser' => 'test_admin', + 'iuname' => 'Test Administrator' + ]; + $mockInstaller = $this->createMockInstaller($config); + + $mockGacl = $this->createMock(\OpenEMR\Gacl\GaclApi::class); + + $mockInstaller->method('newGaclApi') + ->willReturn($mockGacl); + + // Mock basic calls + $mockGacl->method('add_object_section')->willReturn(true); + $mockGacl->method('add_acl')->willReturn(true); + + // Mock add_group to return incremental IDs + $groupIdCounter = 0; + $mockGacl->method('add_group') + ->willReturnCallback(function () use (&$groupIdCounter) { + return ++$groupIdCounter; + }); + + // Mock add_object - verify the user ARO is created correctly + $addObjectCalls = []; + $mockGacl->method('add_object') + ->willReturnCallback(function ($section, $name, $identifier, $order, $hidden, $type) use (&$addObjectCalls) { + $addObjectCalls[] = [$section, $name, $identifier, $type]; + return true; + }); + + // Mock add_group_object - verify the user is added to admin group + $addGroupObjectCalls = []; + $mockGacl->method('add_group_object') + ->willReturnCallback(function ($groupId, $section, $identifier, $type) use (&$addGroupObjectCalls) { + $addGroupObjectCalls[] = [$groupId, $section, $identifier, $type]; + return true; + }); + + $result = $mockInstaller->install_gacl(); + + $this->assertTrue($result); + + // Verify the user ARO was created correctly + $this->assertContains(['users', 'Test Administrator', 'test_admin', 'ARO'], $addObjectCalls); + + // Verify the user was added to the admin group (group ID 2) + $this->assertContains([2, 'users', 'test_admin', 'ARO'], $addGroupObjectCalls); + } + + public function testInstallGaclCreatesExpectedSections(): void + { + $mockInstaller = $this->createMockInstaller(); + $mockGacl = $this->createMock(\OpenEMR\Gacl\GaclApi::class); + + $mockInstaller->method('newGaclApi')->willReturn($mockGacl); + + // Define expected sections in order + $expectedSections = [ + ['Accounting', 'acct'], + ['Administration', 'admin'], + ['Encounters', 'encounters'], + ['Lists', 'lists'], + ['Patients', 'patients'], + ['Squads', 'squads'], + ['Sensitivities', 'sensitivities'], + ['Placeholder', 'placeholder'], + ['Nation Notes', 'nationnotes'], + ['Patient Portal', 'patientportal'], + ['Menus', 'menus'], + ['Groups', 'groups'], + ['Inventory', 'inventory'], + ['Users', 'users'] // ARO section + ]; + + // Expect all sections to be created + $mockGacl->expects($this->exactly(count($expectedSections))) + ->method('add_object_section') + ->willReturnCallback(function ($name, $identifier) use ($expectedSections) { + static $callCount = 0; + $expected = $expectedSections[$callCount]; + $this->assertEquals($expected[0], $name); + $this->assertEquals($expected[1], $identifier); + $callCount++; + return true; + }); + + // Mock other methods to avoid errors + $mockGacl->method('add_object')->willReturn(true); + $mockGacl->method('add_group')->willReturn(1); + $mockGacl->method('add_group_object')->willReturn(true); + $mockGacl->method('add_acl')->willReturn(true); + + $result = $mockInstaller->install_gacl(); + + $this->assertTrue($result); + } + + public function testInstallGaclCreatesExpectedGroups(): void + { + $mockInstaller = $this->createMockInstaller(); + $mockGacl = $this->createMock(\OpenEMR\Gacl\GaclApi::class); + + $mockInstaller->method('newGaclApi')->willReturn($mockGacl); + + $expectedGroups = [ + ['users', 'OpenEMR Users', 0], + ['admin', 'Administrators', 1], // 1 is the users group ID + ['clin', 'Clinicians', 1], + ['doc', 'Physicians', 1], + ['front', 'Front Office', 1], + ['back', 'Accounting', 1], + ['breakglass', 'Emergency Login', 1] + ]; + + // Mock add_group calls and return incremental IDs + $callCount = 0; + $mockGacl->expects($this->exactly(count($expectedGroups))) + ->method('add_group') + ->willReturnCallback(function ($identifier, $name, $parent) use ($expectedGroups, &$callCount) { + $expected = $expectedGroups[$callCount]; + $this->assertEquals($expected[0], $identifier); + $this->assertEquals($expected[1], $name); + $this->assertEquals($expected[2], $parent); + return ++$callCount; // Return group ID + }); + + // Mock other methods + $mockGacl->method('add_object_section')->willReturn(true); + $mockGacl->method('add_object')->willReturn(true); + $mockGacl->method('add_group_object')->willReturn(true); + $mockGacl->method('add_acl')->willReturn(true); + + $result = $mockInstaller->install_gacl(); + + $this->assertTrue($result); + } + + public function testInstallGaclSetsAdministratorPermissions(): void + { + $mockInstaller = $this->createMockInstaller(); + $mockGacl = $this->createMock(\OpenEMR\Gacl\GaclApi::class); + + $mockInstaller->method('newGaclApi')->willReturn($mockGacl); + + // Mock the basic setup calls + $mockGacl->method('add_object_section')->willReturn(true); + $mockGacl->method('add_object')->willReturn(true); + $mockGacl->method('add_group')->willReturn(2); // Return admin group ID = 2 + $mockGacl->method('add_group_object')->willReturn(true); + + // The key test: verify administrator ACL is created correctly + $expectedAdminAcos = [ + 'acct' => ['bill', 'disc', 'eob', 'rep', 'rep_a'], + 'admin' => ['calendar', 'database', 'forms', 'practice', 'superbill', 'users', 'batchcom', 'language', 'super', 'drugs', 'acl','multipledb','menu','manage_modules'], + 'encounters' => ['auth_a', 'auth', 'coding_a', 'coding', 'notes_a', 'notes', 'date_a', 'relaxed'], + 'inventory' => ['lots', 'sales', 'purchases', 'transfers', 'adjustments', 'consumption', 'destruction', 'reporting'], + 'lists' => ['default','state','country','language','ethrace'], + 'patients' => ['appt', 'demo', 'med', 'trans', 'docs', 'notes', 'sign', 'reminder', 'alert', 'disclosure', 'rx', 'amendment', 'lab', 'docs_rm','pat_rep'], + 'sensitivities' => ['normal', 'high'], + 'nationnotes' => ['nn_configure'], + 'patientportal' => ['portal'], + 'menus' => ['modle'], + 'groups' => ['gadd','gcalendar','glog','gdlog','gm'] + ]; + + // Expect add_acl to be called multiple times, we'll verify the first one (admin) + $mockGacl->expects($this->atLeastOnce()) + ->method('add_acl') + ->willReturnCallback(function ($acos, $aros1, $aros2, $axos1, $axos2, $enabled, $enabled2, $access, $note) use ($expectedAdminAcos) { + static $firstCall = true; + if ($firstCall) { + // Verify the first ACL call is for administrators + $this->assertEquals($expectedAdminAcos, $acos); + $this->assertEquals([2], $aros2); // Admin group ID + $this->assertEquals('write', $access); + $this->assertStringContainsString('Administrators can do anything', $note); + $firstCall = false; + } + return true; + }); + + $result = $mockInstaller->install_gacl(); + + $this->assertTrue($result); + } + + public function testInstallGaclWithDefaultUserConfig(): void + { + // Test with default config values + $mockInstaller = $this->createMockInstaller(); // No custom config + $mockGacl = $this->createMock(\OpenEMR\Gacl\GaclApi::class); + + $mockInstaller->method('newGaclApi')->willReturn($mockGacl); + + // Mock basic calls + $mockGacl->method('add_object_section')->willReturn(true); + $mockGacl->method('add_object')->willReturn(true); + $mockGacl->method('add_group')->willReturn(1); + $mockGacl->method('add_acl')->willReturn(true); + + // Test default user values are used + $mockGacl->expects($this->once()) + ->method('add_group_object') + ->with(1, 'users', 'openemr', 'ARO') // Default iuser value + ->willReturn(true); + + $result = $mockInstaller->install_gacl(); + + $this->assertTrue($result); + } + + public function testInstallGaclCreatesAllObjectTypes(): void + { + $mockInstaller = $this->createMockInstaller(); + $mockGacl = $this->createMock(\OpenEMR\Gacl\GaclApi::class); + + $mockInstaller->method('newGaclApi')->willReturn($mockGacl); + + // Track calls to add_object to ensure all expected objects are created + $addObjectCalls = []; + $mockGacl->method('add_object') + ->willReturnCallback(function ($section, $name, $identifier, $order, $hidden, $type) use (&$addObjectCalls) { + $addObjectCalls[] = [$section, $identifier, $type]; + return true; + }); + + // Mock other methods + $mockGacl->method('add_object_section')->willReturn(true); + $mockGacl->method('add_group')->willReturn(1); + $mockGacl->method('add_group_object')->willReturn(true); + $mockGacl->method('add_acl')->willReturn(true); + + $result = $mockInstaller->install_gacl(); + + $this->assertTrue($result); + + // Verify some key objects were created + $this->assertContains(['acct', 'bill', 'ACO'], $addObjectCalls); + $this->assertContains(['admin', 'super', 'ACO'], $addObjectCalls); + $this->assertContains(['patients', 'demo', 'ACO'], $addObjectCalls); + $this->assertContains(['users', 'openemr', 'ARO'], $addObjectCalls); // Default user + + // Should have created many objects (ACOs and at least one ARO) + $this->assertGreaterThan(50, count($addObjectCalls)); + } + + private function createMockInstallerWithoutExecuteSql(array $config = []): MockObject + { + $defaultConfig = [ + 'server' => 'localhost', + 'root' => 'root', + 'rootpass' => 'password', + 'port' => '3306', + 'login' => 'openemr', + 'pass' => 'openemr', + 'dbname' => 'openemr', + 'iuser' => 'openemr', + 'iuname' => 'Administrator', + 'iuserpass' => 'admin', + 'igroup' => 'Default' + ]; + + $config = array_merge($defaultConfig, $config); + + // Mock all methods except execute_sql so we can test it + $mockMethods = [ + 'atEndOfFile', + 'closeFile', + 'createTotpInstance', + 'cryptoGenClassExists', + 'die', + 'encryptTotpSecret', + 'escapeDatabaseName', + 'escapeSql', + 'fileExists', + 'getLine', + 'globPattern', + 'load_file', + 'mysqliErrno', + 'mysqliError', + 'mysqliFetchArray', + 'mysqliInit', + 'mysqliNumRows', + 'mysqliQuery', + 'mysqliRealConnect', + 'mysqliSelectDb', + 'mysqliSslSet', + 'newGaclApi', + 'openFile', + 'recurse_copy', + 'set_collation', + 'set_sql_strict', + 'totpClassExists', + 'touchFile', + 'unlinkFile', + 'user_database_connection', + 'writeToFile', + ]; + + return $this->getMockBuilder(Installer::class) + ->setConstructorArgs([$config]) + ->onlyMethods($mockMethods) + ->getMock(); + } + + // Test execute_sql through drop_database() - Success case + public function testExecuteSqlSuccessViaDropDatabase(): void + { + $mockInstaller = $this->createMockInstallerWithoutExecuteSql(); + $mockMysqli = $this->createMock(mysqli::class); + $mockResult = $this->createMock(mysqli_result::class); + + // Set up the database connection + $mockInstaller->dbh = $mockMysqli; + + $mockInstaller->expects($this->once()) + ->method('escapeDatabaseName') + ->with('openemr') + ->willReturn('`openemr`'); + + $mockInstaller->expects($this->once()) + ->method('mysqliQuery') + ->with($mockMysqli, "drop database if exists `openemr`") + ->willReturn(true); // DDL statements return boolean + + $result = $mockInstaller->drop_database(); + + $this->assertTrue($result); + $this->assertEmpty($mockInstaller->error_message); + } + + // Test execute_sql through drop_database() - Query failure with error logging + public function testExecuteSqlFailureWithErrorViaDropDatabase(): void + { + $mockInstaller = $this->createMockInstallerWithoutExecuteSql(); + $mockMysqli = $this->createMock(mysqli::class); + + // Set up the database connection + $mockInstaller->dbh = $mockMysqli; + + $mockInstaller->expects($this->once()) + ->method('escapeDatabaseName') + ->with('openemr') + ->willReturn('`openemr`'); + + $mockInstaller->expects($this->once()) + ->method('mysqliQuery') + ->with($mockMysqli, "drop database if exists `openemr`") + ->willReturn(false); + + $mockInstaller->expects($this->once()) + ->method('mysqliError') + ->with($mockMysqli) + ->willReturn('Access denied for user'); + + $result = $mockInstaller->drop_database(); + + $this->assertFalse($result); + $this->assertStringContainsString('unable to execute SQL', $mockInstaller->error_message); + $this->assertStringContainsString('Access denied for user', $mockInstaller->error_message); + } + + // Test execute_sql auto-connection when no dbh exists + public function testExecuteSqlAutoConnectsWhenNoDbh(): void + { + $mockInstaller = $this->createMockInstallerWithoutExecuteSql(); + $mockMysqli = $this->createMock(mysqli::class); + + // Initially no database connection + $mockInstaller->dbh = false; + + $mockInstaller->expects($this->once()) + ->method('escapeDatabaseName') + ->with('openemr') + ->willReturn('`openemr`'); + + $mockInstaller->expects($this->once()) + ->method('user_database_connection') + ->willReturnCallback(function () use ($mockInstaller, $mockMysqli) { + $mockInstaller->dbh = $mockMysqli; + return true; + }); + + $mockInstaller->expects($this->once()) + ->method('mysqliQuery') + ->with($mockMysqli, "drop database if exists `openemr`") + ->willReturn(true); + + $result = $mockInstaller->drop_database(); + + $this->assertTrue($result); + } + + // Test execute_sql exception handling through mysqli_sql_exception + public function testExecuteSqlHandlesException(): void + { + $mockInstaller = $this->createMockInstallerWithoutExecuteSql(); + $mockMysqli = $this->createMock(mysqli::class); + + // Set up the database connection + $mockInstaller->dbh = $mockMysqli; + + $mockInstaller->expects($this->once()) + ->method('escapeDatabaseName') + ->with('openemr') + ->willReturn('`openemr`'); + + $exception = new mysqli_sql_exception('SQL exception occurred', 1234); + + $mockInstaller->expects($this->once()) + ->method('mysqliQuery') + ->with($mockMysqli, "drop database if exists `openemr`") + ->willThrowException($exception); + + $result = $mockInstaller->drop_database(); + + $this->assertFalse($result); + $this->assertStringContainsString('unable to execute SQL', $mockInstaller->error_message); + $this->assertStringContainsString('SQL exception occurred', $mockInstaller->error_message); + } + + // Test execute_sql with showError=false - no error message should be set when query fails + public function testExecuteSqlWithShowErrorFalse(): void + { + $mockInstaller = $this->createMockInstallerWithoutExecuteSql(); + $mockMysqli = $this->createMock(mysqli::class); + $mockResult = $this->createMock(mysqli_result::class); + + // Set up the database connection + $mockInstaller->dbh = $mockMysqli; + + // Mock escapeSql calls + $mockInstaller->expects($this->exactly(3)) + ->method('escapeSql') + ->willReturnArgument(0); + + // First query with showError=false fails - should not log error + // Second query (global_priv) succeeds + // Third query (CREATE USER) succeeds + $mockInstaller->expects($this->exactly(3)) + ->method('mysqliQuery') + ->willReturnOnConsecutiveCalls(false, $mockResult, true); + + // mysqliNumRows called for checking user existence + $mockInstaller->expects($this->once()) + ->method('mysqliNumRows') + ->with($mockResult) + ->willReturn(0); + + // mysqliError should NOT be called for the first query (showError=false) + $mockInstaller->expects($this->never()) + ->method('mysqliError'); + + $result = $mockInstaller->create_database_user(); + + $this->assertTrue($result); + // Error message should be empty because the first failed query had showError=false + $this->assertEmpty($mockInstaller->error_message); + } + + private function createMockInstallerWithoutConnectToDatabase(array $config = []): MockObject + { + $defaultConfig = [ + 'server' => 'localhost', + 'root' => 'root', + 'rootpass' => 'password', + 'port' => '3306', + 'login' => 'openemr', + 'pass' => 'openemr', + 'dbname' => 'openemr', + 'site' => 'default' + ]; + + $config = array_merge($defaultConfig, $config); + + // Mock all methods except connect_to_database so we can test it + $mockMethods = [ + 'atEndOfFile', + 'closeFile', + 'createTotpInstance', + 'cryptoGenClassExists', + 'die', + 'encryptTotpSecret', + 'escapeDatabaseName', + 'escapeSql', + 'execute_sql', + 'fileExists', + 'getLine', + 'globPattern', + 'load_file', + 'mysqliErrno', + 'mysqliError', + 'mysqliFetchArray', + 'mysqliInit', + 'mysqliNumRows', + 'mysqliQuery', + 'mysqliRealConnect', + 'mysqliSelectDb', + 'mysqliSslSet', + 'newGaclApi', + 'openFile', + 'recurse_copy', + 'set_collation', + 'set_sql_strict', + 'totpClassExists', + 'touchFile', + 'unlinkFile', + 'writeToFile', + ]; + + return $this->getMockBuilder(Installer::class) + ->setConstructorArgs([$config]) + ->onlyMethods($mockMethods) + ->getMock(); + } + + // Test connect_to_database through root_database_connection() - Success without SSL + public function testConnectToDatabaseSuccessNoSSL(): void + { + $mockInstaller = $this->createMockInstallerWithoutConnectToDatabase(); + $mockMysqli = $this->createMock(mysqli::class); + + // Mock mysqliInit to return mock mysqli object + $mockInstaller->expects($this->once()) + ->method('mysqliInit') + ->willReturn($mockMysqli); + + // No SSL certificates exist + $mockInstaller->expects($this->once()) + ->method('fileExists') + ->willReturn(false); + + // Mock successful connection + $mockInstaller->expects($this->once()) + ->method('mysqliRealConnect') + ->with( + $mockMysqli, + 'localhost', + 'root', + 'password', + '', + 3306, + '', + 0 // No SSL flags + ) + ->willReturn(true); + + $mockInstaller->expects($this->once()) + ->method('set_sql_strict') + ->willReturn(true); + + $result = $mockInstaller->root_database_connection(); + + $this->assertTrue($result); + $this->assertEquals($mockMysqli, $mockInstaller->dbh); + } + + // Test connect_to_database through user_database_connection() - Success with SSL CA only + public function testConnectToDatabaseSuccessSSLCAOnly(): void + { + $mockInstaller = $this->createMockInstallerWithoutConnectToDatabase(); + $mockMysqli = $this->createMock(mysqli::class); + + // Mock mysqliInit to return mock mysqli object + $mockInstaller->expects($this->once()) + ->method('mysqliInit') + ->willReturn($mockMysqli); + + // Mock fileExists calls for SSL certificates + // First call checks mysql-ca (exists), then mysql-key (doesn't exist, short-circuits) + $mockInstaller->expects($this->exactly(2)) + ->method('fileExists') + ->willReturnOnConsecutiveCalls(true, false); + + // Mock SSL setup (CA only, no client certs) + $mockInstaller->expects($this->once()) + ->method('mysqliSslSet') + ->with( + $mockMysqli, + null, // no key + null, // no cert + $this->stringContains('mysql-ca'), // CA file + null, + null + ); + + // Mock successful SSL connection + $mockInstaller->expects($this->once()) + ->method('mysqliRealConnect') + ->with( + $mockMysqli, + 'localhost', + 'openemr', + 'openemr', + 'openemr', + 3306, + '', + MYSQLI_CLIENT_SSL // SSL flags + ) + ->willReturn(true); + + $mockInstaller->expects($this->once()) + ->method('set_sql_strict') + ->willReturn(true); + + $mockInstaller->expects($this->once()) + ->method('set_collation') + ->willReturn(true); + + $mockInstaller->expects($this->once()) + ->method('mysqliSelectDb') + ->with($mockMysqli, 'openemr') + ->willReturn(true); + + $result = $mockInstaller->user_database_connection(); + + $this->assertTrue($result); + $this->assertEquals($mockMysqli, $mockInstaller->dbh); + } + + // Test connect_to_database - Success with full SSL (CA + client cert/key) + public function testConnectToDatabaseSuccessFullSSL(): void + { + $mockInstaller = $this->createMockInstallerWithoutConnectToDatabase(); + $mockMysqli = $this->createMock(mysqli::class); + + $mockInstaller->expects($this->once()) + ->method('mysqliInit') + ->willReturn($mockMysqli); + + // All SSL certificates exist + $mockInstaller->expects($this->exactly(3)) + ->method('fileExists') + ->willReturn(true); + + // Mock SSL setup with client certificates + $mockInstaller->expects($this->once()) + ->method('mysqliSslSet') + ->with( + $mockMysqli, + $this->stringContains('mysql-key'), + $this->stringContains('mysql-cert'), + $this->stringContains('mysql-ca'), + null, + null + ); + + $mockInstaller->expects($this->once()) + ->method('mysqliRealConnect') + ->with( + $mockMysqli, + 'localhost', + 'root', + 'password', + '', + 3306, + '', + MYSQLI_CLIENT_SSL + ) + ->willReturn(true); + + $mockInstaller->expects($this->once()) + ->method('set_sql_strict') + ->willReturn(true); + + $result = $mockInstaller->root_database_connection(); + + $this->assertTrue($result); + $this->assertEquals($mockMysqli, $mockInstaller->dbh); + } + + // Test connect_to_database - Connection failure + public function testConnectToDatabaseConnectionFailure(): void + { + $mockInstaller = $this->createMockInstallerWithoutConnectToDatabase(); + $mockMysqli = $this->createMock(mysqli::class); + + $mockInstaller->expects($this->once()) + ->method('mysqliInit') + ->willReturn($mockMysqli); + + $mockInstaller->expects($this->once()) + ->method('fileExists') + ->willReturn(false); + + // Mock connection failure + $mockInstaller->expects($this->once()) + ->method('mysqliRealConnect') + ->willReturn(false); + + $result = $mockInstaller->root_database_connection(); + + $this->assertFalse($result); + $this->assertStringContainsString('unable to connect to database as root', $mockInstaller->error_message); + } + + // Test connect_to_database - Exception handling + public function testConnectToDatabaseExceptionHandling(): void + { + $mockInstaller = $this->createMockInstallerWithoutConnectToDatabase(); + $mockMysqli = $this->createMock(mysqli::class); + + $mockInstaller->expects($this->once()) + ->method('mysqliInit') + ->willReturn($mockMysqli); + + $mockInstaller->expects($this->once()) + ->method('fileExists') + ->willReturn(false); + + $exception = new mysqli_sql_exception('Connection timeout', 2002); + + // Mock connection throwing exception + $mockInstaller->expects($this->once()) + ->method('mysqliRealConnect') + ->willThrowException($exception); + + $result = $mockInstaller->root_database_connection(); + + $this->assertFalse($result); + $this->assertStringContainsString('unable to connect to database as root', $mockInstaller->error_message); + } + + // Test connect_to_database - Custom port handling + public function testConnectToDatabaseCustomPort(): void + { + $mockInstaller = $this->createMockInstallerWithoutConnectToDatabase(['port' => '3307']); + $mockMysqli = $this->createMock(mysqli::class); + + $mockInstaller->expects($this->once()) + ->method('mysqliInit') + ->willReturn($mockMysqli); + + $mockInstaller->expects($this->once()) + ->method('fileExists') + ->willReturn(false); + + // Expect custom port to be used + $mockInstaller->expects($this->once()) + ->method('mysqliRealConnect') + ->with( + $mockMysqli, + 'localhost', + 'root', + 'password', + '', + 3307, // Custom port + '', + 0 + ) + ->willReturn(true); + + $mockInstaller->expects($this->once()) + ->method('set_sql_strict') + ->willReturn(true); + + $result = $mockInstaller->root_database_connection(); + + $this->assertTrue($result); + } + + // Test connect_to_database exception handling through user_database_connection + // (preserves the original error message from connect_to_database) + public function testConnectToDatabaseExceptionHandlingViaUser(): void + { + $mockInstaller = $this->createMockInstallerWithoutConnectToDatabase(); + $mockMysqli = $this->createMock(mysqli::class); + + $mockInstaller->expects($this->once()) + ->method('mysqliInit') + ->willReturn($mockMysqli); + + $mockInstaller->expects($this->once()) + ->method('fileExists') + ->willReturn(false); + + $exception = new mysqli_sql_exception('Connection timeout', 2002); + + // Mock connection throwing exception + $mockInstaller->expects($this->once()) + ->method('mysqliRealConnect') + ->willThrowException($exception); + + $result = $mockInstaller->user_database_connection(); + + $this->assertFalse($result); + // user_database_connection overrides the error message from connect_to_database + $this->assertStringContainsString("unable to connect to database as user: 'openemr'", $mockInstaller->error_message); + } + + // Test connect_to_database regular connection failure through user_database_connection + public function testConnectToDatabaseRegularFailureViaUser(): void + { + $mockInstaller = $this->createMockInstallerWithoutConnectToDatabase(); + $mockMysqli = $this->createMock(mysqli::class); + + $mockInstaller->expects($this->once()) + ->method('mysqliInit') + ->willReturn($mockMysqli); + + $mockInstaller->expects($this->once()) + ->method('fileExists') + ->willReturn(false); + + // Mock connection failure + $mockInstaller->expects($this->once()) + ->method('mysqliRealConnect') + ->willReturn(false); + + $result = $mockInstaller->user_database_connection(); + + $this->assertFalse($result); + // user_database_connection sets its own error message for failed connections + $this->assertStringContainsString("unable to connect to database as user: 'openemr'", $mockInstaller->error_message); + } + + // Test extractFileName method through displayThemesDivs output + public function testExtractFileNameThroughDisplayThemes(): void + { + $mockInstaller = $this->createMockInstaller(); + + // Mock scanDir to return specific theme files to test extractFileName logic + $mockInstaller->method('scanDir') + ->with('public/images/stylesheets/') + ->willReturn(['.', '..', 'theme_modern_light.png', 'theme_bootstrap_blue_dark.png']); + + ob_start(); + $mockInstaller->displayThemesDivs(); + $output = ob_get_clean(); + + // Verify extractFileName correctly parsed the theme names + $this->assertStringContainsString('Modern Light', $output); // theme_modern_light.png -> Modern Light + $this->assertStringContainsString('Bootstrap Blue Dark', $output); // theme_bootstrap_blue_dark.png -> Bootstrap Blue Dark + $this->assertStringContainsString('value=\'modern_light\'', $output); + $this->assertStringContainsString('value=\'bootstrap_blue_dark\'', $output); + } + + // Test listThemes method with mocked scanDir + public function testListThemes(): void + { + $mockInstaller = $this->createMockInstaller(); + + // Mock scanDir to return sample directory listing + $mockInstaller->expects($this->once()) + ->method('scanDir') + ->with('public/images/stylesheets/') + ->willReturn(['.', '..', 'theme_modern_light.png', 'theme_classic_dark.png', '.gitignore']); + + $result = $mockInstaller->listThemes(); + + // Should filter out . and .. and other dot files + $expected = ['theme_modern_light.png', 'theme_classic_dark.png']; + $this->assertEquals($expected, $result); + } + + // Test listThemes with empty directory + public function testListThemesEmptyDirectory(): void + { + $mockInstaller = $this->createMockInstaller(); + + $mockInstaller->expects($this->once()) + ->method('scanDir') + ->with('public/images/stylesheets/') + ->willReturn(['.', '..']); + + $result = $mockInstaller->listThemes(); + + $this->assertEmpty($result); + } + + // Test listThemes with mixed file types + public function testListThemesMixedFiles(): void + { + $mockInstaller = $this->createMockInstaller(); + + $mockInstaller->expects($this->once()) + ->method('scanDir') + ->with('public/images/stylesheets/') + ->willReturn(['.', '..', 'theme1.png', 'theme2.jpg', 'readme.txt', '.hidden', 'theme3.gif']); + + $result = $mockInstaller->listThemes(); + + // Should include all non-dot files + $expected = ['theme1.png', 'theme2.jpg', 'readme.txt', 'theme3.gif']; + $this->assertEquals($expected, $result); + } + + // Test displayThemesDivs with multiple themes using real listThemes and extractFileName + public function testDisplayThemesDivsIntegration(): void + { + $mockInstaller = $this->createMockInstaller(); + + // Mock scanDir to provide theme files + $mockInstaller->method('scanDir') + ->with('public/images/stylesheets/') + ->willReturn(['.', '..', 'theme_modern_light.png', 'theme_classic_dark.png']); + + ob_start(); + $mockInstaller->displayThemesDivs(); + $output = ob_get_clean(); + + // Verify the output contains expected elements from real extractFileName + $this->assertStringContainsString('

', $output); + $this->assertStringContainsString('Modern Light', $output); + $this->assertStringContainsString('Classic Dark', $output); + + // Verify radio button structure + $this->assertStringContainsString('name=\'stylesheet\'', $output); + $this->assertStringContainsString('type=\'radio\'', $output); + $this->assertStringContainsString('value=\'modern_light\'', $output); + $this->assertStringContainsString('value=\'classic_dark\'', $output); + + // Verify image paths + $this->assertStringContainsString('public/images/stylesheets/theme_modern_light.png', $output); + $this->assertStringContainsString('public/images/stylesheets/theme_classic_dark.png', $output); + } + + // Test displayThemesDivs with no themes + public function testDisplayThemesDivsNoThemes(): void + { + $mockInstaller = $this->createMockInstaller(); + + // Mock scanDir to return empty directory + $mockInstaller->method('scanDir') + ->with('public/images/stylesheets/') + ->willReturn(['.', '..']); + + ob_start(); + $mockInstaller->displayThemesDivs(); + $output = ob_get_clean(); + + // With no themes, the loop shouldn't execute, so output should be empty + $this->assertEmpty($output); + } + + // Test displayThemesDivs with exactly 6 themes (one complete row) + public function testDisplayThemesDivsCompleteRow(): void + { + $mockInstaller = $this->createMockInstaller(); + + $mockFiles = ['.', '..', + 'theme_1.png', 'theme_2.png', 'theme_3.png', + 'theme_4.png', 'theme_5.png', 'theme_6.png' + ]; + + $mockInstaller->method('scanDir') + ->with('public/images/stylesheets/') + ->willReturn($mockFiles); + + ob_start(); + $mockInstaller->displayThemesDivs(); + $output = ob_get_clean(); + + // Should have one complete row that starts and ends properly + $this->assertStringContainsString('
', $output); + $this->assertStringContainsString('
', $output); + $this->assertStringContainsString('
', $output); + + // Should have 6 radio buttons + $this->assertEquals(6, substr_count($output, 'type=\'radio\'')); + } + + // Test displayThemesDivs with 7 themes (partial second row) + public function testDisplayThemesDivsPartialRow(): void + { + $mockInstaller = $this->createMockInstaller(); + + $mockFiles = ['.', '..', + 'theme_1.png', 'theme_2.png', 'theme_3.png', + 'theme_4.png', 'theme_5.png', 'theme_6.png', + 'theme_7.png' + ]; + + $mockInstaller->method('scanDir') + ->with('public/images/stylesheets/') + ->willReturn($mockFiles); + + ob_start(); + $mockInstaller->displayThemesDivs(); + $output = ob_get_clean(); + + // Should have two row starts (positions 0 and 6) + $this->assertEquals(2, substr_count($output, '
')); + // Should have multiple
tags (one per theme div + row ending divs) + $this->assertGreaterThan(0, substr_count($output, '
')); + // Should have 7 radio buttons + $this->assertEquals(7, substr_count($output, 'type=\'radio\'')); + } + + // Test getCurrentTheme method + public function testGetCurrentTheme(): void + { + $mockInstaller = $this->createMockInstaller(); + $mockResult = $this->createMock(mysqli_result::class); + + $mockInstaller->expects($this->once()) + ->method('execute_sql') + ->with("SELECT gl_value FROM globals WHERE gl_name LIKE '%css_header%'") + ->willReturn($mockResult); + + $mockInstaller->expects($this->once()) + ->method('mysqliFetchArray') + ->with($mockResult) + ->willReturn(['style_light.css']); + + $result = $mockInstaller->getCurrentTheme(); + + $this->assertEquals('style_light.css', $result); + } + + // Test setCurrentTheme method when new_theme is set + public function testSetCurrentThemeWithNewTheme(): void + { + $mockInstaller = $this->createMockInstaller(['new_theme' => 'style_dark.css']); + $mockResult = $this->createMock(mysqli_result::class); + + // setCurrentTheme calls getCurrentTheme first to get current theme + $mockInstaller->expects($this->exactly(2)) + ->method('execute_sql') + ->willReturnCallback(function ($sql) use ($mockResult) { + if (strpos($sql, 'SELECT') !== false) { + return $mockResult; + } else { + return true; + } + }); + + $mockInstaller->expects($this->once()) + ->method('mysqliFetchArray') + ->with($mockResult) + ->willReturn(['style_light.css']); + + $mockInstaller->expects($this->once()) + ->method('escapeSql') + ->with('style_dark.css') + ->willReturn('style_dark.css'); + + $result = $mockInstaller->setCurrentTheme(); + + $this->assertTrue($result); + } + + // Test setCurrentTheme method when new_theme is not set (gets current theme) + public function testSetCurrentThemeWithoutNewTheme(): void + { + $mockInstaller = $this->createMockInstaller(['new_theme' => '']); + $mockResult = $this->createMock(mysqli_result::class); + + // Should call getCurrentTheme when new_theme is empty + $mockInstaller->expects($this->exactly(2)) + ->method('execute_sql') + ->willReturnCallback(function ($sql) use ($mockResult) { + if (strpos($sql, 'SELECT') !== false) { + return $mockResult; + } else { + return true; + } + }); + + $mockInstaller->expects($this->once()) + ->method('mysqliFetchArray') + ->with($mockResult) + ->willReturn(['style_light.css']); + + $mockInstaller->expects($this->once()) + ->method('escapeSql') + ->with('style_light.css') + ->willReturn('style_light.css'); + + $result = $mockInstaller->setCurrentTheme(); + + $this->assertTrue($result); + $this->assertEquals('style_light.css', $mockInstaller->new_theme); + } + + // Test displaySelectedThemeDiv method + public function testDisplaySelectedThemeDiv(): void + { + $mockInstaller = $this->createMockInstaller(); + $mockResult = $this->createMock(mysqli_result::class); + + // Mock getCurrentTheme call + $mockInstaller->expects($this->once()) + ->method('execute_sql') + ->with("SELECT gl_value FROM globals WHERE gl_name LIKE '%css_header%'") + ->willReturn($mockResult); + + $mockInstaller->expects($this->once()) + ->method('mysqliFetchArray') + ->with($mockResult) + ->willReturn(['style_modern_light.css']); + + ob_start(); + $mockInstaller->displaySelectedThemeDiv(); + $output = ob_get_clean(); + + // Verify output contains expected elements + $this->assertStringContainsString('
', $output); + $this->assertStringContainsString('Modern Light', $output); // From extractFileName + $this->assertStringContainsString('public/images/stylesheets/style_modern_light.png', $output); + $this->assertStringContainsString('id="current_theme"', $output); + $this->assertStringContainsString('id="current_theme_title"', $output); + } + + // Test displayNewThemeDiv method with new_theme set + public function testDisplayNewThemeDivWithNewTheme(): void + { + $mockInstaller = $this->createMockInstaller(['new_theme' => 'style_dark_blue.css']); + + ob_start(); + $mockInstaller->displayNewThemeDiv(); + $output = ob_get_clean(); + + // Verify output contains expected elements + $this->assertStringContainsString('
', $output); + $this->assertStringContainsString('Dark Blue', $output); // From extractFileName + $this->assertStringContainsString('public/images/stylesheets/style_dark_blue.png', $output); + $this->assertStringContainsString('id="current_theme"', $output); + $this->assertStringContainsString('id="current_theme_title"', $output); + } + + // Test displayNewThemeDiv method without new_theme (gets current theme) + public function testDisplayNewThemeDivWithoutNewTheme(): void + { + $mockInstaller = $this->createMockInstaller(['new_theme' => '']); + $mockResult = $this->createMock(mysqli_result::class); + + // Should call getCurrentTheme when new_theme is empty + $mockInstaller->expects($this->once()) + ->method('execute_sql') + ->with("SELECT gl_value FROM globals WHERE gl_name LIKE '%css_header%'") + ->willReturn($mockResult); + + $mockInstaller->expects($this->once()) + ->method('mysqliFetchArray') + ->with($mockResult) + ->willReturn(['style_classic_green.css']); + + ob_start(); + $mockInstaller->displayNewThemeDiv(); + $output = ob_get_clean(); + + // Verify output contains expected elements from current theme + $this->assertStringContainsString('
', $output); + $this->assertStringContainsString('Classic Green', $output); // From extractFileName + $this->assertStringContainsString('public/images/stylesheets/style_classic_green.png', $output); + $this->assertEquals('style_classic_green.css', $mockInstaller->new_theme); + } + + // Test setupHelpModal method + public function testSetupHelpModal(): void + { + $mockInstaller = $this->createMockInstaller(); + + ob_start(); + $mockInstaller->setupHelpModal(); + $output = ob_get_clean(); + + // Verify modal HTML structure + $this->assertStringContainsString('