From 6eb942dc5b6cebf22b3dc04432f95b66ae5446af Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Mon, 17 Aug 2026 22:19:54 -0700 Subject: [PATCH 1/5] fix: normalize nullable notification text Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + README.md | 3 +++ tests/Unit/TholdStrReplaceTest.php | 9 +++++++++ thold_functions.php | 4 ++-- 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae48cd43..faa95323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ * issue#710: Fixing Typo in thold_daemons.service File * issue#714: Increase the Name column to 255 characters * issue#719: Plugin Disabled due to mix of string and int +* issue#814: Normalize nullable notification template text before replacement * issue: All Columns checkd on Thresholds page * issue: Special character previous value handling broken on data query indexes with special characters diff --git a/README.md b/README.md index 46310dce..109dc656 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ and become familiar with its settings. From there, you can provide overall control of thold, and set defaults for things like Email bodies, weekend exemptions, alert log retention, logging, etc. +Optional notification template fields that are unset are treated as empty +text, so an empty description or replacement does not abort delivery. + As with much of Cacti, settings should be documented in line with the actual setting. If you find that any of these settings are ambiguous, please create a pull request with your proposed changes. diff --git a/tests/Unit/TholdStrReplaceTest.php b/tests/Unit/TholdStrReplaceTest.php index 69006c39..71c20d0c 100644 --- a/tests/Unit/TholdStrReplaceTest.php +++ b/tests/Unit/TholdStrReplaceTest.php @@ -91,4 +91,13 @@ public function testEveryOccurrenceIsReplaced(): void { public function testSubjectWithoutTheTagIsUnchanged(): void { $this->assertSame('no tags here', thold_str_replace('', 5, 'no tags here')); } + + /** + * @return void + */ + public function testNullableInputsAreNormalizedAtTheBoundary(): void { + $this->assertSame('', thold_str_replace('', 'value', null)); + $this->assertSame('subject', thold_str_replace(null, 'value', 'subject')); + $this->assertSame('', thold_str_replace(null, null, null)); + } } diff --git a/thold_functions.php b/thold_functions.php index e174214e..9bce08f6 100644 --- a/thold_functions.php +++ b/thold_functions.php @@ -8375,8 +8375,8 @@ function thold_rlike_clause($value) { * @param mixed $replace Value to substitute. * @param string $subject Text containing the tag. */ -function thold_str_replace(string $search, $replace, string $subject): string { - return str_replace($search, $replace ?? '', $subject); +function thold_str_replace($search, $replace, $subject): string { + return str_replace((string) ($search ?? ''), (string) ($replace ?? ''), (string) ($subject ?? '')); } function thold_template_import($xml_data) { From 71547bdf21d55ae53f70b5a63f785fd90a7d9974 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Mon, 17 Aug 2026 22:38:23 -0700 Subject: [PATCH 2/5] fix: preserve notification replacement type checks Signed-off-by: Thomas Vincent --- tests/Unit/TholdStrReplaceTest.php | 5 ++--- thold_functions.php | 10 +++++----- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/Unit/TholdStrReplaceTest.php b/tests/Unit/TholdStrReplaceTest.php index 71c20d0c..56daee88 100644 --- a/tests/Unit/TholdStrReplaceTest.php +++ b/tests/Unit/TholdStrReplaceTest.php @@ -95,9 +95,8 @@ public function testSubjectWithoutTheTagIsUnchanged(): void { /** * @return void */ - public function testNullableInputsAreNormalizedAtTheBoundary(): void { + public function testNullableSubjectIsNormalizedAtTheBoundary(): void { $this->assertSame('', thold_str_replace('', 'value', null)); - $this->assertSame('subject', thold_str_replace(null, 'value', 'subject')); - $this->assertSame('', thold_str_replace(null, null, null)); + $this->assertSame('', thold_str_replace('', null, null)); } } diff --git a/thold_functions.php b/thold_functions.php index 9bce08f6..cb1bedc0 100644 --- a/thold_functions.php +++ b/thold_functions.php @@ -8371,12 +8371,12 @@ function thold_rlike_clause($value) { * blanking it produced alert bodies reading "Current value is " for exactly * the case an operator most needs to see. * - * @param string $search Tag to replace. - * @param mixed $replace Value to substitute. - * @param string $subject Text containing the tag. + * @param string $search Tag to replace. + * @param mixed $replace Value to substitute. + * @param string|null $subject Text containing the tag. */ -function thold_str_replace($search, $replace, $subject): string { - return str_replace((string) ($search ?? ''), (string) ($replace ?? ''), (string) ($subject ?? '')); +function thold_str_replace(string $search, $replace, ?string $subject): string { + return str_replace($search, $replace ?? '', $subject ?? ''); } function thold_template_import($xml_data) { From a6c841566c5c6fff7fd978b1c6389820235b64f7 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Mon, 17 Aug 2026 22:47:28 -0700 Subject: [PATCH 3/5] test: pin nullable subject deprecation handling Signed-off-by: Thomas Vincent --- README.md | 3 +-- tests/Unit/TholdStrReplaceTest.php | 21 +++++++++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 109dc656..4516eda0 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,7 @@ and become familiar with its settings. From there, you can provide overall control of thold, and set defaults for things like Email bodies, weekend exemptions, alert log retention, logging, etc. -Optional notification template fields that are unset are treated as empty -text, so an empty description or replacement does not abort delivery. +An unset notification template field is treated as empty text. As with much of Cacti, settings should be documented in line with the actual setting. If you find that any of these settings are ambiguous, please create a diff --git a/tests/Unit/TholdStrReplaceTest.php b/tests/Unit/TholdStrReplaceTest.php index 56daee88..9e31619c 100644 --- a/tests/Unit/TholdStrReplaceTest.php +++ b/tests/Unit/TholdStrReplaceTest.php @@ -96,7 +96,24 @@ public function testSubjectWithoutTheTagIsUnchanged(): void { * @return void */ public function testNullableSubjectIsNormalizedAtTheBoundary(): void { - $this->assertSame('', thold_str_replace('', 'value', null)); - $this->assertSame('', thold_str_replace('', null, null)); + $deprecations = []; + set_error_handler(static function ($severity, $message) use (&$deprecations) { + if ($severity === E_DEPRECATED) { + $deprecations[] = $message; + + return true; + } + + return false; + }); + + try { + $this->assertSame('', thold_str_replace('', 'value', null)); + $this->assertSame('', thold_str_replace('', null, null)); + } finally { + restore_error_handler(); + } + + $this->assertSame([], $deprecations); } } From 162a1f5934b621ccc0806dfa008b705dfe3dd2af Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sat, 19 Sep 2026 21:50:02 -0400 Subject: [PATCH 4/5] fix: restore SQL binding, trigger-command quoting, and RPN safety fixes Same set of pre-existing gaps as develop (covered by tests but never implemented in thold_functions.php): - get_allowed_thresholds()/get_allowed_threshold_logs(): bind graph_id and caller-supplied params instead of interpolating them into the SQL text, and use the *_prepared fetchers. - thold_replace_threshold_tags(): add a \ flag that quotes device/threshold free-text values with cacti_escapeshellarg() when the substituted text is a trigger command. - thold_command_execution(): pass shell=true for the three trigger commands, and fix a topic-string typo ('thold' vs 'thold_cmd') that silently suppressed exit-status logging for inline command runs. - thold_expression_math_rpn(): remove a stray break that exited the operator switch before pushing the 0/0 result, flag modulo-by-zero instead of letting PHP throw DivisionByZeroError, reject non-numeric unary operands before eval(), and flag SQRT/LOG results that are NAN or INF instead of pushing them onto the stack. --- thold_functions.php | 87 +++++++++++++++++++++++++++++---------------- 1 file changed, 56 insertions(+), 31 deletions(-) diff --git a/thold_functions.php b/thold_functions.php index 2df1afc9..5a3ec595 100644 --- a/thold_functions.php +++ b/thold_functions.php @@ -363,12 +363,13 @@ function thold_expression_math_rpn($operator, &$stack) { cacti_log('ERROR: RPN value: v2 "' . $v2 . '" is Not valid for operator "' . $operator . '". Stack:"' . implode(',', $orig_stack) . '"', false, 'THOLD'); $rpn_error = true; } elseif ($v1 == 0 && $v2 == 0 && $operator == '/') { + // Not a loop/switch context: this only exits the if/elseif + // chain below, it must not use "break" (which would exit the + // enclosing switch($operator) and skip the array_push below). $v3 = 0; $rpn_evaled = true; - - break; - } elseif ($v1 == 0 && $operator == '/') { - cacti_log('ERROR: RPN value: v1 can not be "0" when the operator is "/". Stack:"' . implode(',', $orig_stack) . '"', false, 'THOLD'); + } elseif ($v1 == 0 && ($operator == '/' || $operator == '%')) { + cacti_log('ERROR: RPN value: v1 can not be "0" when the operator is "' . $operator . '". Stack:"' . implode(',', $orig_stack) . '"', false, 'THOLD'); $rpn_error = true; } @@ -399,9 +400,20 @@ function thold_expression_math_rpn($operator, &$stack) { case 'LOG': $v1 = thold_expression_rpn_pop($stack); + if (!$rpn_error && !is_numeric($v1)) { + cacti_log('ERROR: RPN value: v1 "' . $v1 . '" is Not valid for operator "' . $operator . '". Stack:"' . implode(',', $orig_stack) . '"', false, 'THOLD'); + $rpn_error = true; + } + if (!$rpn_error) { eval('$v2 = ' . $operator . '(' . $v1 . ');'); // nosemgrep: php.lang.security.eval-use.eval-use -- pre-existing RPN expression evaluator; operator is constrained to whitelisted math function names by the parser above - array_push($stack, $v2); + + if (is_nan($v2) || is_infinite($v2)) { + cacti_log('ERROR: RPN value: result of "' . $operator . '(' . $v1 . ')" is undefined. Stack:"' . implode(',', $orig_stack) . '"', false, 'THOLD'); + $rpn_error = true; + } else { + array_push($stack, $v2); + } } break; @@ -1292,7 +1304,7 @@ function thold_calculate_lower_upper($thold, $currentval, $rrd_reindexed) { return $currentval; } -function get_allowed_thresholds($sql_where = '', $order_by = 'td.name', $sql_limit = '', &$total_rows = 0, $user_id = 0, $graph_id = 0) { +function get_allowed_thresholds($sql_where = '', $order_by = 'td.name', $sql_limit = '', &$total_rows = 0, $user_id = 0, $graph_id = 0, $sql_params = []) { if ($sql_limit != '') { $sql_limit = "LIMIT $sql_limit"; } @@ -1301,8 +1313,11 @@ function get_allowed_thresholds($sql_where = '', $order_by = 'td.name', $sql_lim $order_by = "ORDER BY $order_by"; } + $params = $sql_params; + if ($graph_id > 0) { - $sql_where .= (strlen($sql_where) ? ' AND ' : ' ') . " gl.id=$graph_id"; + $sql_where .= (strlen($sql_where) ? ' AND ' : ' ') . " gl.id = ?"; + $params[] = $graph_id; } if (strlen($sql_where)) { @@ -1358,7 +1373,7 @@ function get_allowed_thresholds($sql_where = '', $order_by = 'td.name', $sql_lim $order_by $sql_limit"); - $tholds = db_fetch_assoc($tholds_sql); + $tholds = db_fetch_assoc_prepared($tholds_sql, $params); $sql = "SELECT COUNT(*) FROM ( @@ -1376,15 +1391,15 @@ function get_allowed_thresholds($sql_where = '', $order_by = 'td.name', $sql_lim ) AS rower"; if (function_exists('get_total_row_data') && $graph_id == 0) { - $total_rows = get_total_row_data($user_id, $sql, [], 'thold', 10); + $total_rows = get_total_row_data($user_id, $sql, $params, 'thold', 10); } else { - $total_rows = db_fetch_cell($sql); + $total_rows = db_fetch_cell_prepared($sql, $params); } return $tholds; } -function get_allowed_threshold_logs($sql_where = '', $order_by = 'td.name', $sql_limit = '', &$total_rows = 0, $user_id = 0, $graph_id = 0) { +function get_allowed_threshold_logs($sql_where = '', $order_by = 'td.name', $sql_limit = '', &$total_rows = 0, $user_id = 0, $graph_id = 0, $sql_params = []) { if ($sql_limit != '') { $sql_limit = "LIMIT $sql_limit"; } @@ -1393,8 +1408,11 @@ function get_allowed_threshold_logs($sql_where = '', $order_by = 'td.name', $sql $order_by = "ORDER BY $order_by"; } + $params = $sql_params; + if ($graph_id > 0) { - $sql_where .= (strlen($sql_where) ? ' AND ' : ' ') . " gl.id = $graph_id"; + $sql_where .= (strlen($sql_where) ? ' AND ' : ' ') . " gl.id = ?"; + $params[] = $graph_id; } if (strlen($sql_where)) { @@ -1428,7 +1446,7 @@ function get_allowed_threshold_logs($sql_where = '', $order_by = 'td.name', $sql $sql_where = get_policy_where($graph_auth_method, $policies, $sql_where); } - $tholds = db_fetch_assoc("SELECT + $tholds = db_fetch_assoc_prepared("SELECT tl.`id`, tl.`time`, tl.`host_id`, tl.`local_graph_id`, tl.`threshold_id`, IF(IFNULL(tl.`threshold_value`,'')='',NULL,(tl.`threshold_value` + 0.0)) AS `threshold_value`, IF(IFNULL(tl.`current`,'')='',NULL,(tl.`current` + 0.0)) AS `current`, tl.`status`, tl.`type`, @@ -1446,7 +1464,7 @@ function get_allowed_threshold_logs($sql_where = '', $order_by = 'td.name', $sql ON h.id=gl.host_id $sql_where $order_by - $sql_limit"); + $sql_limit", $params); $sql = "SELECT COUNT(*) FROM ( @@ -1466,9 +1484,9 @@ function get_allowed_threshold_logs($sql_where = '', $order_by = 'td.name', $sql ) AS rower"; if (function_exists('get_total_row_data') && $graph_id == 0) { - $total_rows = get_total_row_data($user_id, $sql, [], 'thold_log', 10); + $total_rows = get_total_row_data($user_id, $sql, $params, 'thold_log', 10); } else { - $total_rows = db_fetch_cell($sql); + $total_rows = db_fetch_cell_prepared($sql, $params); } return $tholds; @@ -4065,7 +4083,7 @@ function thold_command_execution(&$thold_data, &$h, $breach_up, $breach_down, $b $queue = read_config_option('thold_notification_queue'); if ($breach_up && $thold_data['trigger_cmd_high'] != '') { - $cmd = thold_replace_threshold_tags($thold_data['trigger_cmd_high'], $thold_data, $h, $thold_data['lastread'], $thold_data['local_graph_id'], $data_source_name); + $cmd = thold_replace_threshold_tags($thold_data['trigger_cmd_high'], $thold_data, $h, $thold_data['lastread'], $thold_data['local_graph_id'], $data_source_name, true); $cmd = thold_expand_string($thold_data, $cmd); @@ -4085,7 +4103,7 @@ function thold_command_execution(&$thold_data, &$h, $breach_up, $breach_down, $b $command_executed = true; } elseif ($breach_down && $thold_data['trigger_cmd_low'] != '') { - $cmd = thold_replace_threshold_tags($thold_data['trigger_cmd_low'], $thold_data, $h, $thold_data['lastread'], $thold_data['local_graph_id'], $data_source_name); + $cmd = thold_replace_threshold_tags($thold_data['trigger_cmd_low'], $thold_data, $h, $thold_data['lastread'], $thold_data['local_graph_id'], $data_source_name, true); $cmd = thold_expand_string($thold_data, $cmd); $environment = thold_set_environ($thold_data['trigger_cmd_high'], $thold_data, $h, $thold_data['lastread'], $thold_data['local_graph_id'], $data_source_name); @@ -4104,7 +4122,7 @@ function thold_command_execution(&$thold_data, &$h, $breach_up, $breach_down, $b $command_executed = true; } elseif ($breach_norm && $thold_data['trigger_cmd_norm'] != '') { - $cmd = thold_replace_threshold_tags($thold_data['trigger_cmd_norm'], $thold_data, $h, $thold_data['lastread'], $thold_data['local_graph_id'], $data_source_name); + $cmd = thold_replace_threshold_tags($thold_data['trigger_cmd_norm'], $thold_data, $h, $thold_data['lastread'], $thold_data['local_graph_id'], $data_source_name, true); $cmd = thold_expand_string($thold_data, $cmd); $environment = thold_set_environ($thold_data['trigger_cmd_high'], $thold_data, $h, $thold_data['lastread'], $thold_data['local_graph_id'], $data_source_name); @@ -4125,7 +4143,7 @@ function thold_command_execution(&$thold_data, &$h, $breach_up, $breach_down, $b } if ($queue == '' && $command_executed) { - thold_process_command_output($output, $return, 'thold', $thold_data, $cmd); + thold_process_command_output($output, $return, 'thold_cmd', $thold_data, $cmd); } } } @@ -4239,7 +4257,7 @@ function thold_set_environ($text, &$thold, &$h, $currentval, $local_graph_id, $d return $environment; } -function thold_replace_threshold_tags($text, &$thold, &$h, $currentval, $local_graph_id, $data_source_name) { +function thold_replace_threshold_tags($text, &$thold, &$h, $currentval, $local_graph_id, $data_source_name, $shell = false) { global $thold_types; if (substr(read_config_option('base_url'), 0, 4) != 'http') { @@ -4263,26 +4281,33 @@ function thold_replace_threshold_tags($text, &$thold, &$h, $currentval, $local_g $site = __('Default', 'thold'); } + // Device and threshold free-text values are admin/user editable. When $text + // is a trigger command template ($shell), quote them so they cannot + // terminate the command and start another. + $quote = function ($value) use ($shell) { + return $shell ? cacti_escapeshellarg((string) $value) : $value; + }; + // Do some replacement of variables - $text = thold_str_replace('', $h['description'], $text); - $text = thold_str_replace('', $h['hostname'], $text); - $text = thold_str_replace('', $h['location'], $text); - $text = thold_str_replace('', $site, $text); + $text = thold_str_replace('', $quote($h['description']), $text); + $text = thold_str_replace('', $quote($h['hostname']), $text); + $text = thold_str_replace('', $quote($h['location']), $text); + $text = thold_str_replace('', $quote($site), $text); $text = thold_str_replace('', $local_graph_id, $text); $text = thold_str_replace('', $thold['id'], $text); - $text = thold_str_replace('', $currentval, $text); - $text = thold_str_replace('', $thold['name_cache'], $text); + $text = thold_str_replace('', $quote($currentval), $text); + $text = thold_str_replace('', $quote($thold['name_cache']), $text); $text = thold_str_replace('', $data_source_name, $text); if (isset($thold_types[$thold['thold_type']])) { $text = thold_str_replace('', $thold_types[$thold['thold_type']], $text); } - $text = thold_str_replace('', $thold['notes'], $text); - $text = thold_str_replace('', $thold['dnotes'], $text); - $text = thold_str_replace('', $thold['dnotes'], $text); - $text = thold_str_replace('', $thold['external_id'], $text); + $text = thold_str_replace('', $quote($thold['notes']), $text); + $text = thold_str_replace('', $quote($thold['dnotes']), $text); + $text = thold_str_replace('', $quote($thold['dnotes']), $text); + $text = thold_str_replace('', $quote($thold['external_id']), $text); if ($thold['thold_type'] == 0) { $text = thold_str_replace('', $thold['thold_hi'], $text); From f41e9676608c07db5046c255ed015a4b7395a2c7 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sat, 19 Sep 2026 22:06:56 -0400 Subject: [PATCH 5/5] fix(tests): stop includes/arrays.php loading from silently no-op'ing thold_functions.php includes includes/arrays.php with a plain include() (not include_once()) from several of its own functions (e.g. thold_log()), keyed off \['base_path']. Once any test in the shared Pest process exercised one of those call sites, PHP's include-once registry considered the file already included: a later thold_test_load() (require_once) on the same resolved path silently no-op'd and never (re)published \ to \, depending on which order the test files happened to run in. This intermittently broke every test that reads \ (TholdReplaceThresholdTagsTest's THOLDTYPE substitution) as well as tests several call frames downstream of thold_log()/thold_check_threshold() (NotificationEmailDeduplicationTest, ThresholdTimeBasedCharacterizationTest), depending on file execution order. Add thold_test_load_always()/loadPluginSourceAlways(), which use a plain require() instead of require_once(), for plugin files that only assign file-scope variables (no function/class declarations) and are therefore safe to load more than once. Switch the four test classes that load includes/arrays.php to the new helper. --- tests/TestCase.php | 19 +++++++++++ tests/Unit/TholdReplaceThresholdTagsTest.php | 2 +- .../ThresholdBaselineCharacterizationTest.php | 2 +- .../ThresholdHiLowCharacterizationTest.php | 2 +- ...ThresholdTimeBasedCharacterizationTest.php | 2 +- tests/bootstrap-unit.php | 32 +++++++++++++++++++ 6 files changed, 55 insertions(+), 4 deletions(-) diff --git a/tests/TestCase.php b/tests/TestCase.php index 951504e6..cb46b047 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -59,6 +59,25 @@ protected static function loadPluginSource($file) { thold_test_load(dirname(__DIR__) . '/' . $file); } + /** + * Load a plugin source file that only assigns file-scope variables (e.g. + * includes/arrays.php), bypassing the include-once registry. + * + * thold_functions.php includes includes/arrays.php with a plain include() + * (not include_once()) from several of its own functions. Once any test + * exercises one of those call sites, a later loadPluginSource() of the + * same file silently no-ops (require_once sees it as already included) + * and never (re)publishes $thold_types to $GLOBALS. Use this for any + * plugin file that only assigns variables, not functions/classes. + * + * @param string $file File name relative to the plugin root. + * + * @return void + */ + protected static function loadPluginSourceAlways($file) { + thold_test_load_always(dirname(__DIR__) . '/' . $file); + } + /** * Define the plugin's own constants by running the function that owns them. * diff --git a/tests/Unit/TholdReplaceThresholdTagsTest.php b/tests/Unit/TholdReplaceThresholdTagsTest.php index 96006c94..3bcc6ec7 100644 --- a/tests/Unit/TholdReplaceThresholdTagsTest.php +++ b/tests/Unit/TholdReplaceThresholdTagsTest.php @@ -30,7 +30,7 @@ public static function setUpBeforeClass(): void { self::loadPluginSource('thold_functions.php'); // Defines $thold_types, which the substitution reads. - self::loadPluginSource('includes/arrays.php'); + self::loadPluginSourceAlways('includes/arrays.php'); } /** diff --git a/tests/Unit/ThresholdBaselineCharacterizationTest.php b/tests/Unit/ThresholdBaselineCharacterizationTest.php index a510eaf0..932e3a9c 100644 --- a/tests/Unit/ThresholdBaselineCharacterizationTest.php +++ b/tests/Unit/ThresholdBaselineCharacterizationTest.php @@ -28,7 +28,7 @@ final class ThresholdBaselineCharacterizationTest extends TestCase { */ public static function setUpBeforeClass(): void { self::loadPluginSource('thold_functions.php'); - self::loadPluginSource('includes/arrays.php'); + self::loadPluginSourceAlways('includes/arrays.php'); self::loadPluginConstants(); } diff --git a/tests/Unit/ThresholdHiLowCharacterizationTest.php b/tests/Unit/ThresholdHiLowCharacterizationTest.php index d23613f5..a392d9ab 100644 --- a/tests/Unit/ThresholdHiLowCharacterizationTest.php +++ b/tests/Unit/ThresholdHiLowCharacterizationTest.php @@ -28,7 +28,7 @@ final class ThresholdHiLowCharacterizationTest extends TestCase { */ public static function setUpBeforeClass(): void { self::loadPluginSource('thold_functions.php'); - self::loadPluginSource('includes/arrays.php'); + self::loadPluginSourceAlways('includes/arrays.php'); self::loadPluginConstants(); } diff --git a/tests/Unit/ThresholdTimeBasedCharacterizationTest.php b/tests/Unit/ThresholdTimeBasedCharacterizationTest.php index 5bc0e5af..c231072c 100644 --- a/tests/Unit/ThresholdTimeBasedCharacterizationTest.php +++ b/tests/Unit/ThresholdTimeBasedCharacterizationTest.php @@ -27,7 +27,7 @@ final class ThresholdTimeBasedCharacterizationTest extends TestCase { */ public static function setUpBeforeClass(): void { self::loadPluginSource('thold_functions.php'); - self::loadPluginSource('includes/arrays.php'); + self::loadPluginSourceAlways('includes/arrays.php'); self::loadPluginConstants(); } diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index 1a0214f2..07523500 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -591,3 +591,35 @@ function thold_test_load($path) { } } } + +/** + * Load a plugin source file that only assigns file-scope variables (no + * function/class declarations), publishing them to $GLOBALS the same way as + * thold_test_load(). + * + * includes/arrays.php is also included with a plain include() (not + * include_once()) from several places in thold_functions.php itself (e.g. + * thold_log()), keyed off $config['base_path']. Once any test exercises one + * of those call sites, PHP's include-once registry considers the file + * already included: a later thold_test_load() (require_once) on the same + * resolved path silently no-ops and never (re)publishes $thold_types. Using + * a plain require() here sidesteps that registry entirely, so re-running it + * is always safe and cheap for a file that just assigns arrays. + * + * @param string $path Absolute path to the file. + * + * @return void + */ +function thold_test_load_always($path) { + global $config; + + $__before = get_defined_vars(); + + require $path; + + foreach (get_defined_vars() as $__name => $__value) { + if (!array_key_exists($__name, $__before) && strncmp($__name, '__', 2) !== 0) { + $GLOBALS[$__name] = $__value; + } + } +}