From 59eae072a577b95ec218592658328a7c27b70198 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 16 Aug 2026 20:04:33 -0700 Subject: [PATCH 01/11] test: pin the hi/low threshold evaluator's behaviour thold_check_threshold() has no return value: everything it decides is a side effect through seven global Cacti functions. ThresholdScenario builds the fixture those need and runs one poll; ThresholdOutcome reads back what was emitted, so a test asserts on behaviour rather than on the SQL text. No production file is touched. Several assertions record behaviour that is wrong rather than intended, each with a comment saying so, so that fixing it later is a deliberate edit here. Signed-off-by: Thomas Vincent --- tests/Support/CactiStub.php | 214 ++++++++ tests/Support/ThresholdOutcome.php | 205 +++++++ tests/Support/ThresholdScenario.php | 223 ++++++++ .../ThresholdHiLowCharacterizationTest.php | 269 +++++++++ tests/bootstrap.php | 515 ++++++++++++++++++ tests/docker/Dockerfile | 29 + tests/docker/docker-compose.yml | 15 + 7 files changed, 1470 insertions(+) create mode 100644 tests/Support/CactiStub.php create mode 100644 tests/Support/ThresholdOutcome.php create mode 100644 tests/Support/ThresholdScenario.php create mode 100644 tests/Unit/ThresholdHiLowCharacterizationTest.php create mode 100644 tests/bootstrap.php create mode 100644 tests/docker/Dockerfile create mode 100644 tests/docker/docker-compose.yml diff --git a/tests/Support/CactiStub.php b/tests/Support/CactiStub.php new file mode 100644 index 00000000..4b408d05 --- /dev/null +++ b/tests/Support/CactiStub.php @@ -0,0 +1,214 @@ +}> + */ + public static $calls = []; + + /** + * Queued return values, keyed by function name. Each call shifts one value + * off the front; an exhausted queue falls back to the type default. + * + * @var array> + */ + public static $returns = []; + + /** + * Return values chosen by a fragment of the SQL, keyed by function name. + * Each entry is [fragment, value]. Consulted before $returns. + * + * @var array> + */ + public static $matchedReturns = []; + + /** + * Values handed back on every call, keyed by function name. Consulted last. + * + * @var array + */ + public static $stickyReturns = []; + + /** + * Values handed back by the get_*_request_var() family, keyed by var name. + * + * @var array + */ + public static $requestVars = []; + + /** + * Values handed back by read_config_option(), keyed by option name. + * + * @var array + */ + public static $configOptions = []; + + /** + * Messages passed to cacti_log(), in order. + * + * @var array + */ + public static $log = []; + + /** + * Mail handed to Cacti's mailer(), in order. + * + * @var array + */ + public static $mail = []; + + /** + * Clear all recorded and programmed state. + * + * @return void + */ + public static function reset() { + self::$calls = []; + self::$returns = []; + self::$matchedReturns = []; + self::$stickyReturns = []; + self::$requestVars = []; + self::$configOptions = []; + self::$log = []; + self::$mail = []; + } + + /** + * Record one Cacti function call. + * + * @param string $fn Cacti function name. + * @param string $sql SQL text, or '' for non-query calls. + * @param array $params Bound parameters, if any. + * + * @return void + */ + public static function record($fn, $sql = '', array $params = []) { + self::$calls[] = ['fn' => $fn, 'sql' => $sql, 'params' => $params]; + } + + /** + * Hand back $value for every call to $fn. + * + * @param string $fn Cacti function name. + * @param mixed $value Value to hand back. + * + * @return void + */ + public static function willAlwaysReturn($fn, $value) { + self::$stickyReturns[$fn] = $value; + } + + /** + * Queue one return value for the next call to $fn. + * + * @param string $fn Cacti function name. + * @param mixed $value Value to hand back. + * + * @return void + */ + public static function willReturn($fn, $value) { + self::$returns[$fn][] = $value; + } + + /** + * Answer any call to $fn whose SQL contains $fragment with $value. + * + * A function such as db_fetch_cell_prepared is called many times with + * different queries in one run, so a positional queue would break as soon + * as the code under test reordered a lookup. Matching on the query keeps + * the fixture readable and stable. + * + * @param string $fn Cacti function name. + * @param string $fragment Distinctive substring of the SQL. + * @param mixed $value Value to hand back. + * + * @return void + */ + public static function willReturnFor($fn, $fragment, $value) { + self::$matchedReturns[$fn][] = [$fragment, $value]; + } + + /** + * Take the return value for a call: a SQL match first, then the queue, then + * the type default. + * + * @param string $fn Cacti function name. + * @param mixed $default Fallback when nothing matches. + * @param string $sql SQL the caller passed, for matching. + * + * @return mixed + */ + public static function nextReturn($fn, $default, $sql = '') { + if ($sql !== '' && !empty(self::$matchedReturns[$fn])) { + $flat = preg_replace('/\s+/', ' ', $sql); + + foreach (self::$matchedReturns[$fn] as $entry) { + if (strpos($flat, preg_replace('/\s+/', ' ', $entry[0])) !== false) { + return $entry[1]; + } + } + } + + if (!empty(self::$returns[$fn])) { + return array_shift(self::$returns[$fn]); + } + + if (array_key_exists($fn, self::$stickyReturns)) { + return self::$stickyReturns[$fn]; + } + + return $default; + } + + /** + * All recorded calls to $fn. + * + * @param string $fn Cacti function name. + * + * @return array}> + */ + public static function callsTo($fn) { + return array_values(array_filter(self::$calls, function ($call) use ($fn) { + return $call['fn'] === $fn; + })); + } + + /** + * The recorded call log reduced to function names, in order. Useful for + * asserting transaction sequencing. + * + * @return array + */ + public static function callSequence() { + return array_column(self::$calls, 'fn'); + } +} diff --git a/tests/Support/ThresholdOutcome.php b/tests/Support/ThresholdOutcome.php new file mode 100644 index 00000000..5787b0b8 --- /dev/null +++ b/tests/Support/ThresholdOutcome.php @@ -0,0 +1,205 @@ + + */ + public $thold; + + /** + * @param array $thold + */ + public function __construct(array $thold) { + $this->thold = $thold; + } + + /** + * Subject lines of the mail that was sent, in order. + * + * @return array + */ + public function subjects() { + return array_column(CactiStub::$mail, 'subject'); + } + + /** + * Recipients of the mail that was sent, in order. + * + * @return array + */ + public function recipients() { + return array_column(CactiStub::$mail, 'to'); + } + + /** + * @return int + */ + public function mailCount() { + return count(CactiStub::$mail); + } + + /** + * Status codes written to plugin_thold_log, in order. + * + * The log row goes through sql_save(), so the status is available as data + * rather than having to be parsed back out of a query. + * + * @return array + */ + public function logStatuses() { + $statuses = []; + + foreach (CactiStub::callsTo('sql_save') as $call) { + if ($call['sql'] === 'plugin_thold_log' && isset($call['params']['status'])) { + $statuses[] = (int) $call['params']['status']; + } + } + + return $statuses; + } + + /** + * @return int + */ + public function trapCount() { + return count(CactiStub::callsTo('cacti_snmp_send')); + } + + /** + * Whether the run marked the threshold as having changed state. + * + * @return bool + */ + public function touchedLastChanged() { + foreach (CactiStub::callsTo('db_execute_prepared') as $call) { + if (strpos($call['sql'], 'lastchanged = NOW()') !== false) { + return true; + } + } + + return false; + } + + /** + * Whether the run set the acknowledgment flag. + * + * @return bool + */ + public function acknowledged() { + foreach (CactiStub::callsTo('db_execute_prepared') as $call) { + if (strpos($call['sql'], 'acknowledgment = "on"') !== false) { + return true; + } + } + + return false; + } + + /** + * Columns the run wrote to thold_data, resolved to their values. + * + * The statements mix placeholders and literals in the same SET clause, so + * the clause is parsed and each "?" resolved against the bound parameters + * in order. Returns the merge of every such statement, later writes last. + * + * @return array + */ + public function persistedColumns() { + $columns = []; + + foreach (CactiStub::callsTo('db_execute_prepared') as $call) { + if (strpos($call['sql'], 'UPDATE thold_data') === false) { + continue; + } + + if (!preg_match('/SET\s+(.*?)\s+WHERE/s', $call['sql'], $clause)) { + continue; + } + + $position = 0; + + foreach (explode(',', $clause[1]) as $assignment) { + $parts = explode('=', $assignment, 2); + + if (count($parts) !== 2) { + continue; + } + + $name = trim($parts[0]); + $value = trim($parts[1]); + + if ($value === '?') { + $value = isset($call['params'][$position]) ? (string) $call['params'][$position] : ''; + $position++; + } + + $columns[$name] = trim($value, '"\''); + } + } + + return $columns; + } + + /** + * The alert state the run persisted, or null when it wrote none. + * + * @return int|null + */ + public function persistedAlertState() { + $columns = $this->persistedColumns(); + + return isset($columns['thold_alert']) ? (int) $columns['thold_alert'] : null; + } + + /** + * The fail counts the run persisted, or null when it wrote neither. + * + * @return array{alert: int|null, warning: int|null}|null + */ + public function persistedFailCounts() { + $columns = $this->persistedColumns(); + + if (!isset($columns['thold_fail_count']) && !isset($columns['thold_warning_fail_count'])) { + return null; + } + + return [ + 'alert' => isset($columns['thold_fail_count']) ? (int) $columns['thold_fail_count'] : null, + 'warning' => isset($columns['thold_warning_fail_count']) ? (int) $columns['thold_warning_fail_count'] : null, + ]; + } + + /** + * Whether the run did nothing at all beyond reading. + * + * @return bool + */ + public function isSilent() { + return $this->mailCount() === 0 + && $this->logStatuses() === [] + && $this->trapCount() === 0 + && CactiStub::callsTo('thold_command_execution') === []; + } +} diff --git a/tests/Support/ThresholdScenario.php b/tests/Support/ThresholdScenario.php new file mode 100644 index 00000000..15f3dedb --- /dev/null +++ b/tests/Support/ThresholdScenario.php @@ -0,0 +1,223 @@ + + */ + private $thold; + + /** + * A threshold row with every column the function reads, set to values that + * on their own produce no breach and no notification. + * + * @param array $overrides Columns to change. + */ + private function __construct(array $overrides) { + $this->thold = $overrides + [ + 'id' => 1, + 'name' => 'CPU utilisation', + 'name_cache' => 'CPU utilisation', + 'host_id' => 2, + 'local_data_id' => 4, + 'local_graph_id' => 7, + 'data_template_rrd_id' => 9, + 'data_source_name' => 'traffic_in', + 'thold_type' => 0, + 'data_type' => 0, + 'lastread' => 50, + 'oldvalue' => 50, + 'lasttime' => 0, + 'rrd_step' => 300, + + 'thold_hi' => '', + 'thold_low' => '', + 'thold_warning_hi' => '', + 'thold_warning_low' => '', + 'thold_fail_trigger' => 1, + 'thold_warning_fail_trigger' => 1, + 'thold_fail_count' => 0, + 'thold_warning_fail_count' => 0, + 'thold_alert' => 0, + 'repeat_alert' => 0, + + 'time_hi' => '', + 'time_low' => '', + 'time_fail_trigger' => 1, + 'time_warning_fail_trigger' => 1, + 'time_fail_length' => 300, + 'time_warning_fail_length' => 300, + + 'bl_fail_count' => 0, + 'bl_alert' => 0, + 'bl_pct_down' => '', + 'bl_pct_up' => '', + 'bl_fail_trigger' => 1, + 'bl_ref_time_range' => 3600, + 'bl_type' => 0, + 'bl_cf' => 'AVG', + 'bl_thold_valid' => 0, + + 'notify_warning' => 0, + 'notify_alert' => 0, + 'notify_extra' => '', + 'notify_warning_extra' => '', + 'persist_ack' => '', + 'reset_ack' => '', + 'acknowledgment' => '', + 'exempt' => '', + + 'syslog_enabled' => '', + 'syslog_priority' => 5, + 'syslog_facility' => 1, + 'snmp_event_severity' => 3, + 'snmp_event_description' => '', + 'snmp_engine_id' => '', + + 'trigger_cmd_high' => '', + 'trigger_cmd_low' => '', + 'trigger_cmd_norm' => '', + + 'notes' => '', + 'dnotes' => '', + 'external_id' => '', + 'email_subject' => '', + 'email_subject_warn' => '', + 'email_subject_restoral' => '', + 'restored_alert' => '', + 'graph_timespan' => 7, + 'show_units' => '', + 'units_suffix' => '', + 'decimals' => 2, + 'format_file' => '', + 'thold_enabled' => 'on', + 'thold_daemon_id' => 0, + ]; + } + + /** + * @param array $overrides + * + * @return self + */ + public static function threshold(array $overrides = []) { + $scenario = new self($overrides); + + $scenario->device(); + + return $scenario; + } + + /** + * Program the device row the function loads for the threshold's host. + * + * @param array $overrides + * + * @return self + */ + public function device(array $overrides = []) { + CactiStub::willReturnFor('db_fetch_row_prepared', 'FROM host WHERE id = ?', $overrides + [ + 'id' => 2, + 'description' => 'core-switch-1', + 'hostname' => '10.0.0.1', + 'location' => 'rack 4', + 'site_id' => 1, + 'status' => 3, + 'status_fail_date' => '2026-01-01 00:00:00', + 'status_rec_date' => '2026-01-02 00:00:00', + 'status_last_error' => '', + 'snmp_engine_id' => '', + 'notes' => '', + ]); + + return $this; + } + + /** + * Give the threshold a legacy alert contact, which is what makes the alert + * recipient list non-empty. + * + * @param string $address + * + * @return self + */ + public function alertRecipient($address) { + CactiStub::willReturnFor('db_fetch_assoc_prepared', 'FROM plugin_thold_contacts', [['data' => $address]]); + + return $this; + } + + /** + * @param string $name + * @param mixed $value + * + * @return self + */ + public function option($name, $value) { + CactiStub::$configOptions[$name] = $value; + + return $this; + } + + /** + * Put the device into a maintenance window. + * + * @return self + */ + public function inMaintenance() { + // Asked more than once per poll, so a queued value would run out. + CactiStub::willAlwaysReturn('api_plugin_is_enabled', true); + CactiStub::willAlwaysReturn('plugin_maint_check_cacti_host', true); + + /* + * thold include_once()s the maint plugin when it reports enabled. The + * fixture supplies an empty file so the include succeeds; the function + * it would define is already stubbed. + */ + $maint = dirname(__DIR__, 3) . '/maint'; + + if (!is_dir($maint)) { + mkdir($maint, 0777, true); + } + + if (!file_exists($maint . '/functions.php')) { + file_put_contents($maint . '/functions.php', "thold; + + thold_check_threshold($thold); + + return new ThresholdOutcome($thold); + } +} diff --git a/tests/Unit/ThresholdHiLowCharacterizationTest.php b/tests/Unit/ThresholdHiLowCharacterizationTest.php new file mode 100644 index 00000000..85eb1004 --- /dev/null +++ b/tests/Unit/ThresholdHiLowCharacterizationTest.php @@ -0,0 +1,269 @@ + $overrides + * + * @return ThresholdScenario + */ + private function bounded(array $overrides = []) { + return ThresholdScenario::threshold($overrides + [ + 'thold_hi' => 90, + 'thold_low' => 10, + 'thold_warning_hi' => 80, + 'thold_warning_low' => 20, + ])->alertRecipient('ops@example.org'); + } + + /** + * @return void + */ + public function testReadingInsideBothBoundsEmitsNothing(): void { + $outcome = $this->bounded(['lastread' => 50])->poll(); + + $this->assertTrue($outcome->isSilent()); + } + + /** + * @return void + */ + public function testBreachAtTriggerNotifiesAndLogsTheAlert(): void { + $outcome = $this->bounded(['lastread' => 95, 'thold_fail_trigger' => 1])->poll(); + + $this->assertSame(1, $outcome->mailCount()); + $this->assertSame([ST_NOTIFYAL], $outcome->logStatuses()); + $this->assertTrue($outcome->touchedLastChanged()); + + /* + * The legacy contact list is joined with the global address and the + * device address whether or not those are set, so the To header carries + * trailing empty entries. Recorded, not endorsed. + */ + $this->assertSame(['ops@example.org,,'], $outcome->recipients()); + } + + /** + * @return void + */ + public function testBreachBelowTriggerCountsButDoesNotNotify(): void { + $outcome = $this->bounded([ + 'lastread' => 95, + 'thold_fail_trigger' => 3, + 'thold_fail_count' => 0, + ])->poll(); + + $this->assertSame(0, $outcome->mailCount()); + + /* + * No log row either. ST_TRIGGERA exists for this case but is only + * written by the time-based arm, so a hi/low threshold counting up to + * its trigger leaves no trace in the log. + */ + $this->assertSame([], $outcome->logStatuses()); + + /* + * An alert breach also zeroes the warning counter, so a threshold that + * crosses the warning bound on its way up loses that progress. + */ + $this->assertSame(['alert' => 1, 'warning' => 0], $outcome->persistedFailCounts()); + } + + /** + * The alert state is recorded on the first breaching poll, before the + * trigger count is met, so the interface shows a threshold in alert that + * has not notified and may never do so. + * + * @return void + */ + public function testAlertStateIsRecordedBeforeTheTriggerIsMet(): void { + $outcome = $this->bounded([ + 'lastread' => 95, + 'thold_fail_trigger' => 3, + ])->poll(); + + $this->assertSame(STAT_HI, $outcome->persistedAlertState()); + } + + /** + * @return void + */ + public function testBreachBelowTheLowerBoundRecordsTheLowState(): void { + $outcome = $this->bounded(['lastread' => 5])->poll(); + + $this->assertSame(STAT_LO, $outcome->persistedAlertState()); + $this->assertSame([ST_NOTIFYAL], $outcome->logStatuses()); + } + + /** + * @return void + */ + public function testReadingBetweenWarningAndAlertBoundsNotifiesTheWarning(): void { + $outcome = $this->bounded(['lastread' => 85])->poll(); + + $this->assertSame([ST_NOTIFYWA], $outcome->logStatuses()); + } + + /** + * @return void + */ + public function testRestoralFromAlertNotifiesAndClearsTheState(): void { + $outcome = $this->bounded([ + 'lastread' => 50, + 'thold_alert' => STAT_HI, + 'thold_fail_count' => 3, + ])->poll(); + + $this->assertSame([ST_NOTIFYRS], $outcome->logStatuses()); + $this->assertSame(STAT_NORMAL, $outcome->persistedAlertState()); + $this->assertSame(1, $outcome->mailCount()); + } + + /** + * @return void + */ + public function testRestoralResetsBothFailCounts(): void { + $outcome = $this->bounded([ + 'lastread' => 50, + 'thold_alert' => STAT_HI, + 'thold_fail_count' => 3, + 'thold_warning_fail_count' => 2, + ])->poll(); + + $this->assertSame(['alert' => 0, 'warning' => 0], $outcome->persistedFailCounts()); + } + + /** + * A re-alert fires when the fail count passes the trigger and lands on a + * multiple of repeat_alert. + * + * @return void + */ + public function testRepeatAlertNotifiesAgainOnTheConfiguredInterval(): void { + $outcome = $this->bounded([ + 'lastread' => 95, + 'thold_fail_trigger' => 1, + 'thold_fail_count' => 3, + 'repeat_alert' => 2, + ])->poll(); + + $this->assertSame([ST_NOTIFYRA], $outcome->logStatuses()); + } + + /** + * @return void + */ + public function testRepeatAlertStaysQuietBetweenIntervals(): void { + $outcome = $this->bounded([ + 'lastread' => 95, + 'thold_fail_trigger' => 1, + 'thold_fail_count' => 1, + 'repeat_alert' => 3, + ])->poll(); + + $this->assertSame(0, $outcome->mailCount()); + } + + /** + * @return void + */ + public function testAcknowledgedThresholdDoesNotMailOnBreach(): void { + $outcome = $this->bounded([ + 'lastread' => 95, + 'acknowledgment' => 'on', + ])->poll(); + + $this->assertSame(0, $outcome->mailCount()); + } + + /** + * @return void + */ + public function testPersistAckSetsTheAcknowledgmentOnFirstNotification(): void { + $outcome = $this->bounded([ + 'lastread' => 95, + 'persist_ack' => 'on', + ])->poll(); + + $this->assertTrue($outcome->acknowledged()); + } + + /** + * A device in a maintenance window still evaluates, but must not notify + * and must not advance the fail count. + * + * @return void + */ + public function testMaintenanceWindowSuppressesNotification(): void { + $outcome = $this->bounded(['lastread' => 95])->inMaintenance()->poll(); + + $this->assertSame(0, $outcome->mailCount()); + $this->assertSame([], $outcome->logStatuses()); + } + + /** + * @return void + */ + public function testUnknownReadingEmitsNoAlert(): void { + $outcome = $this->bounded(['lastread' => 'U'])->poll(); + + $this->assertSame(0, $outcome->mailCount()); + } + + /** + * With no bounds configured the threshold can never breach, whatever the + * reading. + * + * @return void + */ + public function testThresholdWithNoBoundsNeverBreaches(): void { + $outcome = ThresholdScenario::threshold(['lastread' => 99999]) + ->alertRecipient('ops@example.org') + ->poll(); + + $this->assertTrue($outcome->isSilent()); + } + + /** + * @return void + */ + public function testDisabledGloballyStopsBeforeAnyEvaluation(): void { + $outcome = $this->bounded(['lastread' => 95]) + ->option('thold_disable_all', 'on') + ->poll(); + + $this->assertTrue($outcome->isSilent()); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 00000000..4278e0f9 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,515 @@ + dirname(dirname(dirname(__DIR__))), + 'url_path' => '/cacti/', + 'cacti_version' => '1.2.31', + 'cacti_server_os' => 'unix', +]; + +// thold_expand_string() include_once()s library_path/variables.php at call time. +$GLOBALS['config']['library_path'] = __DIR__ . '/fixtures/cacti-lib'; + +// thold reads and writes this on every RPN evaluation. +$GLOBALS['rpn_error'] = false; + +// Cacti's list of enabled plugins; thold_check_threshold() declares it global. +$GLOBALS['plugins'] = []; + +// Cacti's debug flag, also declared global by thold_check_threshold(). +$GLOBALS['debug'] = false; + +if (!function_exists('db_execute')) { + function db_execute($sql, $log = true, $db_conn = false) { + CactiStub::record('db_execute', $sql); + + return CactiStub::nextReturn('db_execute', true, $sql); + } +} + +if (!function_exists('db_execute_prepared')) { + function db_execute_prepared($sql, $params = [], $log = true, $db_conn = false) { + CactiStub::record('db_execute_prepared', $sql, $params); + + return CactiStub::nextReturn('db_execute_prepared', true, $sql); + } +} + +if (!function_exists('db_fetch_assoc')) { + function db_fetch_assoc($sql, $log = true, $db_conn = false) { + CactiStub::record('db_fetch_assoc', $sql); + + return CactiStub::nextReturn('db_fetch_assoc', [], $sql); + } +} + +if (!function_exists('db_fetch_assoc_prepared')) { + function db_fetch_assoc_prepared($sql, $params = [], $log = true, $db_conn = false) { + CactiStub::record('db_fetch_assoc_prepared', $sql, $params); + + return CactiStub::nextReturn('db_fetch_assoc_prepared', [], $sql); + } +} + +if (!function_exists('db_fetch_row')) { + function db_fetch_row($sql, $log = true, $db_conn = false) { + CactiStub::record('db_fetch_row', $sql); + + return CactiStub::nextReturn('db_fetch_row', [], $sql); + } +} + +if (!function_exists('db_fetch_row_prepared')) { + function db_fetch_row_prepared($sql, $params = [], $log = true, $db_conn = false) { + CactiStub::record('db_fetch_row_prepared', $sql, $params); + + return CactiStub::nextReturn('db_fetch_row_prepared', [], $sql); + } +} + +if (!function_exists('db_fetch_cell')) { + function db_fetch_cell($sql, $col_name = '', $log = true, $db_conn = false) { + CactiStub::record('db_fetch_cell', $sql); + + return CactiStub::nextReturn('db_fetch_cell', '', $sql); + } +} + +if (!function_exists('db_fetch_cell_prepared')) { + function db_fetch_cell_prepared($sql, $params = [], $col_name = '', $log = true, $db_conn = false) { + CactiStub::record('db_fetch_cell_prepared', $sql, $params); + + return CactiStub::nextReturn('db_fetch_cell_prepared', '', $sql); + } +} + +if (!function_exists('db_qstr')) { + function db_qstr($string) { + return "'" . str_replace("'", "''", (string) $string) . "'"; + } +} + +if (!function_exists('db_begin_transaction')) { + function db_begin_transaction() { + CactiStub::record('db_begin_transaction'); + + return CactiStub::nextReturn('db_begin_transaction', true); + } +} + +if (!function_exists('db_commit_transaction')) { + function db_commit_transaction() { + CactiStub::record('db_commit_transaction'); + + return CactiStub::nextReturn('db_commit_transaction', true); + } +} + +if (!function_exists('db_rollback_transaction')) { + function db_rollback_transaction() { + CactiStub::record('db_rollback_transaction'); + + return CactiStub::nextReturn('db_rollback_transaction', true); + } +} + +if (!function_exists('html_escape')) { + function html_escape($string) { + return htmlspecialchars((string) $string, ENT_QUOTES, 'UTF-8'); + } +} + +/* + * Mirrors Cacti 1.2 lib/functions.php. KEEP IN SYNC: if core tightens its + * checks, tests here would otherwise keep passing while production diverges. + */ +if (!function_exists('sanitize_unserialize_selected_items')) { + function sanitize_unserialize_selected_items($items) { + if (empty($items)) { + return false; + } + + $data = unserialize($items, ['allowed_classes' => false]); // nosemgrep: php.lang.security.unserialize-use.unserialize-use -- test stub mirroring Cacti core; allowed_classes:false blocks object injection + + if (!is_array($data)) { + return false; + } + + foreach ($data as $value) { + if (!is_numeric($value)) { + return false; + } + } + + return $data; + } +} + +if (!function_exists('read_config_option')) { + function read_config_option($name, $force = false) { + return isset(CactiStub::$configOptions[$name]) ? CactiStub::$configOptions[$name] : ''; + } +} + +if (!function_exists('set_config_option')) { + function set_config_option($name, $value) { + CactiStub::$configOptions[$name] = $value; + } +} + +if (!function_exists('__')) { + function __($text) { + $args = array_slice(func_get_args(), 1); + + // Cacti's __() accepts sprintf arguments after the format string. + return $args === [] ? $text : vsprintf($text, $args); + } +} + +if (!function_exists('__esc')) { + function __esc($text) { + return htmlspecialchars(call_user_func_array('__', func_get_args()), ENT_QUOTES, 'UTF-8'); + } +} + +if (!function_exists('cacti_log')) { + function cacti_log($message, $output = false, $environ = 'CMDPHP', $level = 0) { + CactiStub::$log[] = $message; + } +} + +if (!function_exists('cacti_sizeof')) { + function cacti_sizeof($array) { + return (is_array($array) || $array instanceof Countable) ? count($array) : 0; + } +} + +if (!function_exists('cacti_count')) { + function cacti_count($array) { + return cacti_sizeof($array); + } +} + +if (!function_exists('get_request_var')) { + function get_request_var($name, $default = '') { + return isset(CactiStub::$requestVars[$name]) ? CactiStub::$requestVars[$name] : $default; + } +} + +if (!function_exists('get_nfilter_request_var')) { + function get_nfilter_request_var($name, $default = '') { + return get_request_var($name, $default); + } +} + +if (!function_exists('get_filter_request_var')) { + function get_filter_request_var($name, $filter = FILTER_VALIDATE_INT, $options = []) { + return get_request_var($name); + } +} + +if (!function_exists('isset_request_var')) { + function isset_request_var($name) { + return isset(CactiStub::$requestVars[$name]); + } +} + +if (!function_exists('cacti_escapeshellarg')) { + function cacti_escapeshellarg($string, $quote = true) { + return escapeshellarg((string) $string); + } +} + +if (!function_exists('api_plugin_hook_function')) { + function api_plugin_hook_function($name, $data = '') { + return $data; + } +} + +if (!function_exists('get_simple_graph_perms')) { + function get_simple_graph_perms($user_id) { + return CactiStub::nextReturn('get_simple_graph_perms', true); + } +} + +if (!function_exists('get_policies')) { + function get_policies($user_id) { + return CactiStub::nextReturn('get_policies', []); + } +} + +if (!function_exists('get_policy_where')) { + function get_policy_where($graph_auth_method, $policies, $sql_where) { + CactiStub::record('get_policy_where', $sql_where); + + return CactiStub::nextReturn('get_policy_where', $sql_where); + } +} + +if (!function_exists('expand_title')) { + function expand_title($host_id, $snmp_query_id, $snmp_index, $title) { + CactiStub::record('expand_title', $title); + + return CactiStub::nextReturn('expand_title', $title); + } +} + +if (!function_exists('get_graph_title')) { + function get_graph_title($local_graph_id) { + return CactiStub::nextReturn('get_graph_title', 'Traffic - eth0'); + } +} + +if (!function_exists('rrdtool_function_fetch')) { + function rrdtool_function_fetch($local_data_id, $start_time, $end_time, $resolution = 0, $show_unknown = false, $rrdtool_file = null) { + CactiStub::record('rrdtool_function_fetch', (string) $local_data_id); + + return CactiStub::nextReturn('rrdtool_function_fetch', []); + } +} + +if (!function_exists('get_data_source_path')) { + function get_data_source_path($local_data_id, $expand_paths = true) { + return '/var/lib/cacti/rra/test_' . (int) $local_data_id . '.rrd'; + } +} + +if (!function_exists('sql_save')) { + function sql_save($array_items, $table_name, $key_cols = 'id', $autoinc = true, $db_conn = false) { + CactiStub::record('sql_save', $table_name, $array_items); + + return CactiStub::nextReturn('sql_save', 1); + } +} + +if (!function_exists('db_affected_rows')) { + function db_affected_rows($db_conn = false) { + return CactiStub::nextReturn('db_affected_rows', 1); + } +} + +if (!function_exists('rrdtool_function_graph')) { + function rrdtool_function_graph($local_graph_id, $rra_id, $graph_data_array, $rrdtool_pipe = false, &$xport_meta = [], $user = 0) { + CactiStub::record('rrdtool_function_graph', (string) $local_graph_id); + + // A one-pixel PNG stands in for the rendered graph. + return base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==', true); + } +} + +if (!function_exists('get_timespan')) { + function get_timespan(&$timespan, $time, $span, $first_weekdayid) { + $timespan['begin_now'] = $time - 86400; + $timespan['end_now'] = $time; + } +} + +if (!function_exists('read_user_setting')) { + function read_user_setting($config_name, $default = false, $force = false, $user = 0) { + return CactiStub::nextReturn('read_user_setting', $default); + } +} + +if (!function_exists('get_selected_theme')) { + function get_selected_theme() { + return 'modern'; + } +} + +if (!function_exists('mailer')) { + function mailer($from, $to, $cc = '', $bcc = '', $replyto = '', $subject = '', $body = '', $body_text = '', $attachments = null, $headers = [], $html = true) { + CactiStub::$mail[] = [ + 'to' => is_array($to) ? implode(',', $to) : (string) $to, + 'bcc' => is_array($bcc) ? implode(',', $bcc) : (string) $bcc, + 'subject' => (string) $subject, + ]; + + return CactiStub::nextReturn('mailer', ''); + } +} + +if (!function_exists('cacti_snmp_send')) { + function cacti_snmp_send($hostname, $version, $community, $oid, $value, $type = 's') { + CactiStub::record('cacti_snmp_send', (string) $oid); + + return true; + } +} + +if (!function_exists('array_rekey')) { + function array_rekey($array, $key, $key_value) { + $ret_array = []; + + if (is_array($array)) { + foreach ($array as $item) { + $item_key = $item[$key]; + + if (is_array($key_value)) { + foreach ($key_value as $value) { + $ret_array[$item_key][$value] = $item[$value]; + } + } else { + $ret_array[$item_key] = $item[$key_value]; + } + } + } + + return $ret_array; + } +} + +if (!function_exists('clean_up_name')) { + function clean_up_name($string) { + $string = preg_replace('/[\s\.]+/', '_', $string); + $string = preg_replace('/[^a-zA-Z0-9_]+/', '', $string); + + return preg_replace('/_{2,}/', '_', $string); + } +} + +if (!function_exists('plugin_maint_check_cacti_host')) { + function plugin_maint_check_cacti_host($host_id) { + return CactiStub::nextReturn('plugin_maint_check_cacti_host', false); + } +} + +if (!function_exists('api_plugin_is_enabled')) { + function api_plugin_is_enabled($plugin) { + return CactiStub::nextReturn('api_plugin_is_enabled', false); + } +} + +if (!function_exists('api_plugin_hook')) { + function api_plugin_hook($name, $data = '') { + CactiStub::record('api_plugin_hook', $name); + + return $data; + } +} + +if (!function_exists('api_user_realm_auth')) { + function api_user_realm_auth($filename = '') { + return CactiStub::nextReturn('api_user_realm_auth', true); + } +} + +if (!function_exists('raise_message')) { + function raise_message($message_id, $message = '', $level = 0) { + CactiStub::record('raise_message', (string) $message_id); + } +} + +if (!function_exists('rrdtool_execute')) { + function rrdtool_execute($command, $log_to_stdout = false, $output_flag = 1, $rrdtool_pipe = false, $logopt = 'WEBLOG') { + CactiStub::record('rrdtool_execute', $command); + + return CactiStub::nextReturn('rrdtool_execute', ''); + } +} + +if (!function_exists('rrdtool_function_interface_speed')) { + function rrdtool_function_interface_speed($data_local) { + return CactiStub::nextReturn('rrdtool_function_interface_speed', 0); + } +} + +if (!function_exists('get_timeinstate')) { + function get_timeinstate($host) { + return CactiStub::nextReturn('get_timeinstate', '1 day'); + } +} + +if (!function_exists('get_daysfromtime')) { + function get_daysfromtime($timestamp) { + return CactiStub::nextReturn('get_daysfromtime', '1 day'); + } +} + +if (!function_exists('number_format_i18n')) { + function number_format_i18n($number, $decimals = 0, $baseu = 1000) { + return number_format((float) $number, $decimals < 0 ? 0 : (int) $decimals); + } +} + +if (!defined('FILTER_VALIDATE_IS_REGEX')) { + define('FILTER_VALIDATE_IS_REGEX', 99999); +} + +// Device states, from Cacti include/global_constants.php. +foreach (['HOST_UNKNOWN' => 0, 'HOST_DOWN' => 1, 'HOST_RECOVERING' => 2, 'HOST_UP' => 3, 'HOST_ERROR' => 4] as $name => $value) { + if (!defined($name)) { + define($name, $value); + } +} + +if (!defined('RRDTOOL_OUTPUT_STDOUT')) { + define('RRDTOOL_OUTPUT_STDOUT', 1); +} + +if (!defined('CACTI_DATE_TIME_FORMAT')) { + define('CACTI_DATE_TIME_FORMAT', 'Y-m-d H:i:s'); +} + +if (!defined('CACTI_PATH_BASE')) { + define('CACTI_PATH_BASE', $GLOBALS['config']['base_path']); +} + +/** + * Load a plugin source file at global scope. + * + * Several plugin files (includes/arrays.php in particular) define their data + * as file-scope variables that the rest of the plugin reads as globals, and + * they read $config while doing so. Requiring them from inside a method would + * make both halves of that method-local, so the require happens here and any + * variable the file introduced is published to $GLOBALS. + * + * @param string $path Absolute path to the file. + * + * @return void + */ +function thold_test_load($path) { + global $config; + + $__before = get_defined_vars(); + + require_once $path; + + foreach (get_defined_vars() as $__name => $__value) { + if (!array_key_exists($__name, $__before) && strncmp($__name, '__', 2) !== 0) { + $GLOBALS[$__name] = $__value; + } + } +} diff --git a/tests/docker/Dockerfile b/tests/docker/Dockerfile new file mode 100644 index 00000000..5c0a414d --- /dev/null +++ b/tests/docker/Dockerfile @@ -0,0 +1,29 @@ +# Test runner for the Thold plugin. +# +# Pinned to PHP 8.1 because that is the oldest interpreter the CI matrix +# covers; what passes here passes on 8.2-8.4. pcov rather than Xdebug: line +# coverage is the only debug feature the suite needs and pcov is far cheaper. +FROM php:8.1-cli-alpine@sha256:7949370448b0b4d9787776dc5968e0fd8d48763292344b5fbf21539441228a98 + +# git is needed by the changed-line coverage gate, which diffs against the +# base branch. +RUN apk add --no-cache git gmp-dev \ + && docker-php-ext-install gmp \ + && apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \ + && pecl install pcov \ + && docker-php-ext-enable pcov \ + && apk del .build-deps + +COPY --from=composer:2@sha256:4d71c3c2109c61d5415544264b59ad4087e4c5b7244481723664138fd36d5040 /usr/bin/composer /usr/bin/composer + +# The plugin lives where Cacti would put it, because thold_functions.php +# resolves its own includes through $config['base_path'] . '/plugins/thold'. +# No network or database is involved; the Cacti framework functions themselves +# are stubbed in tests/bootstrap.php. +WORKDIR /cacti/plugins/thold + +ENV COMPOSER_ALLOW_SUPERUSER=1 \ + COMPOSER_NO_INTERACTION=1 \ + COMPOSER_CACHE_DIR=/tmp/composer-cache + +CMD ["sh", "-c", "composer install --no-progress --no-ansi && vendor/bin/phpunit"] diff --git a/tests/docker/docker-compose.yml b/tests/docker/docker-compose.yml new file mode 100644 index 00000000..99d38b47 --- /dev/null +++ b/tests/docker/docker-compose.yml @@ -0,0 +1,15 @@ +# Local mirror of the unit-test CI job. `docker compose -f +# tests/docker/docker-compose.yml run --rm phpunit` runs exactly what CI runs. +services: + phpunit: + build: + context: . + dockerfile: Dockerfile + image: cacti-thold-test:php8.1 + working_dir: /cacti/plugins/thold + volumes: + - ../..:/cacti/plugins/thold + - composer-cache:/tmp/composer-cache + +volumes: + composer-cache: From 6e065890447a3bef8e2c75f418ad416013769e08 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 16 Aug 2026 20:07:26 -0700 Subject: [PATCH 02/11] test: pin the time-based threshold evaluator's behaviour Records where this arm has drifted from the hi/low one, notably that a restoral writes the log row and clears the state but sends no mail, so an operator sees the alert and never the all-clear. Cacti's cell fetchers return false rather than '' when a query matches no row. The stub now does the same: on PHP 8 the difference is a TypeError in the re-alert arithmetic, so the old default invented a failure production does not have. Signed-off-by: Thomas Vincent --- tests/Support/ThresholdScenario.php | 11 ++ ...ThresholdTimeBasedCharacterizationTest.php | 152 ++++++++++++++++++ tests/bootstrap.php | 10 +- 3 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/ThresholdTimeBasedCharacterizationTest.php diff --git a/tests/Support/ThresholdScenario.php b/tests/Support/ThresholdScenario.php index 15f3dedb..7d01d361 100644 --- a/tests/Support/ThresholdScenario.php +++ b/tests/Support/ThresholdScenario.php @@ -64,6 +64,8 @@ private function __construct(array $overrides) { 'time_hi' => '', 'time_low' => '', + 'time_warning_hi' => '', + 'time_warning_low' => '', 'time_fail_trigger' => 1, 'time_warning_fail_trigger' => 1, 'time_fail_length' => 300, @@ -126,6 +128,15 @@ public static function threshold(array $overrides = []) { $scenario->device(); + /* + * The time-based arm multiplies this into a window bound; an empty + * value is a fatal on PHP 8 rather than a missing step. + */ + CactiStub::willReturnFor('db_fetch_cell_prepared', 'SELECT rrd_step', 300); + + // Counts of prior log rows; the arms add these together arithmetically. + CactiStub::willReturnFor('db_fetch_cell_prepared', 'SELECT COUNT(id)', 0); + return $scenario; } diff --git a/tests/Unit/ThresholdTimeBasedCharacterizationTest.php b/tests/Unit/ThresholdTimeBasedCharacterizationTest.php new file mode 100644 index 00000000..e5a87fd5 --- /dev/null +++ b/tests/Unit/ThresholdTimeBasedCharacterizationTest.php @@ -0,0 +1,152 @@ + $overrides + * + * @return ThresholdScenario + */ + private function bounded(array $overrides = []) { + return ThresholdScenario::threshold($overrides + [ + 'thold_type' => 2, + 'time_hi' => 90, + 'time_low' => 10, + ])->alertRecipient('ops@example.org'); + } + + /** + * @return void + */ + public function testReadingInsideBothBoundsEmitsNothing(): void { + $outcome = $this->bounded(['lastread' => 50])->poll(); + + $this->assertTrue($outcome->isSilent()); + } + + /** + * @return void + */ + public function testBreachAtTriggerNotifiesAndLogsTheAlert(): void { + $outcome = $this->bounded(['lastread' => 95, 'time_fail_trigger' => 1])->poll(); + + $this->assertSame(1, $outcome->mailCount()); + $this->assertSame([ST_NOTIFYAL], $outcome->logStatuses()); + $this->assertSame(STAT_HI, $outcome->persistedAlertState()); + } + + /** + * @return void + */ + public function testBreachBelowTheLowerBoundRecordsTheLowState(): void { + $outcome = $this->bounded(['lastread' => 5, 'time_fail_trigger' => 1])->poll(); + + $this->assertSame(STAT_LO, $outcome->persistedAlertState()); + } + + /** + * The hi/low arm mails on restoral. This one writes the restoral to the log + * and clears the state, but sends nothing, so an operator watching a + * time-based threshold sees the alert and never the all-clear. + * + * @return void + */ + public function testRestoralLogsButDoesNotMail(): void { + $outcome = $this->bounded([ + 'lastread' => 50, + 'thold_alert' => STAT_HI, + 'thold_fail_count' => 3, + ])->poll(); + + $this->assertSame([ST_NOTIFYRS], $outcome->logStatuses()); + $this->assertSame(STAT_NORMAL, $outcome->persistedAlertState()); + $this->assertSame(0, $outcome->mailCount()); + } + + /** + * @return void + */ + public function testRestoralResetsTheFailCounts(): void { + $outcome = $this->bounded([ + 'lastread' => 50, + 'thold_alert' => STAT_HI, + 'thold_fail_count' => 3, + 'thold_warning_fail_count' => 2, + ])->poll(); + + $this->assertSame(['alert' => 0, 'warning' => 0], $outcome->persistedFailCounts()); + } + + /** + * @return void + */ + public function testMaintenanceWindowSuppressesNotification(): void { + $outcome = $this->bounded(['lastread' => 95, 'time_fail_trigger' => 1]) + ->inMaintenance() + ->poll(); + + $this->assertSame(0, $outcome->mailCount()); + } + + /** + * @return void + */ + public function testAcknowledgedThresholdDoesNotMailOnBreach(): void { + $outcome = $this->bounded([ + 'lastread' => 95, + 'time_fail_trigger' => 1, + 'acknowledgment' => 'on', + ])->poll(); + + $this->assertSame(0, $outcome->mailCount()); + } + + /** + * @return void + */ + public function testUnknownReadingEmitsNoAlert(): void { + $outcome = $this->bounded(['lastread' => 'U'])->poll(); + + $this->assertSame(0, $outcome->mailCount()); + } + + /** + * @return void + */ + public function testThresholdWithNoBoundsNeverBreaches(): void { + $outcome = ThresholdScenario::threshold(['thold_type' => 2, 'lastread' => 99999]) + ->alertRecipient('ops@example.org') + ->poll(); + + $this->assertTrue($outcome->isSilent()); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 4278e0f9..b5a39187 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -100,11 +100,17 @@ function db_fetch_row_prepared($sql, $params = [], $log = true, $db_conn = false } } +/* + * Cacti's cell fetchers return false, not '', when the query matches no row. + * The difference matters on PHP 8: false coerces to 0 in arithmetic while '' + * raises a TypeError, so a stub returning '' invents failures that production + * does not have. + */ if (!function_exists('db_fetch_cell')) { function db_fetch_cell($sql, $col_name = '', $log = true, $db_conn = false) { CactiStub::record('db_fetch_cell', $sql); - return CactiStub::nextReturn('db_fetch_cell', '', $sql); + return CactiStub::nextReturn('db_fetch_cell', false, $sql); } } @@ -112,7 +118,7 @@ function db_fetch_cell($sql, $col_name = '', $log = true, $db_conn = false) { function db_fetch_cell_prepared($sql, $params = [], $col_name = '', $log = true, $db_conn = false) { CactiStub::record('db_fetch_cell_prepared', $sql, $params); - return CactiStub::nextReturn('db_fetch_cell_prepared', '', $sql); + return CactiStub::nextReturn('db_fetch_cell_prepared', false, $sql); } } From e396bf76e82b816c898acd78abe386ebdee616af Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 16 Aug 2026 20:17:09 -0700 Subject: [PATCH 03/11] test: follow Cacti's test layout and composer scripts Adopts the conventions from Cacti core: tests/bootstrap-unit.php, tests/Helpers for the stubs, a phpunit.xml carrying error_reporting -1 and CACTI_TEST_BOOTSTRAP, and composer lint / test / test:coverage scripts so CI runs the same commands a developer does. The dev toolchain is Cacti's, pinned to the same platform php 8.1.0. Cacti core runs Pest and this suite does not, because pest ^2 does not resolve on PHP 8.1 -- the platform Cacti's own composer.json pins. Releases up to v2.36.0 conflict with phpunit 10.5.62 and later, every earlier 10.x release is blocked by advisory PKSA-z3gr-8qht-p93v, and v2.36.1, which does resolve, requires PHP 8.2. The stack installs on 8.2 and above; 8.1 is the floor this plugin's CI matrix targets. The tests are written in the plain PHPUnit class style that Cacti's tests/Pest.php explicitly supports, so they run unchanged under Pest wherever it is installable. --- .../{Support => Helpers}/ThresholdOutcome.php | 24 +- .../ThresholdScenario.php | 14 +- tests/Support/CactiStub.php | 214 ------- tests/bootstrap.php | 521 ------------------ tests/docker/Dockerfile | 2 +- 5 files changed, 20 insertions(+), 755 deletions(-) rename tests/{Support => Helpers}/ThresholdOutcome.php (87%) rename tests/{Support => Helpers}/ThresholdScenario.php (92%) delete mode 100644 tests/Support/CactiStub.php delete mode 100644 tests/bootstrap.php diff --git a/tests/Support/ThresholdOutcome.php b/tests/Helpers/ThresholdOutcome.php similarity index 87% rename from tests/Support/ThresholdOutcome.php rename to tests/Helpers/ThresholdOutcome.php index 5787b0b8..4642467c 100644 --- a/tests/Support/ThresholdOutcome.php +++ b/tests/Helpers/ThresholdOutcome.php @@ -41,7 +41,7 @@ public function __construct(array $thold) { * @return array */ public function subjects() { - return array_column(CactiStub::$mail, 'subject'); + return array_column(CactiStubs::$mail, 'subject'); } /** @@ -50,14 +50,14 @@ public function subjects() { * @return array */ public function recipients() { - return array_column(CactiStub::$mail, 'to'); + return array_column(CactiStubs::$mail, 'to'); } /** * @return int */ public function mailCount() { - return count(CactiStub::$mail); + return count(CactiStubs::$mail); } /** @@ -71,7 +71,7 @@ public function mailCount() { public function logStatuses() { $statuses = []; - foreach (CactiStub::callsTo('sql_save') as $call) { + foreach (CactiStubs::callsTo('sql_save') as $call) { if ($call['sql'] === 'plugin_thold_log' && isset($call['params']['status'])) { $statuses[] = (int) $call['params']['status']; } @@ -84,7 +84,7 @@ public function logStatuses() { * @return int */ public function trapCount() { - return count(CactiStub::callsTo('cacti_snmp_send')); + return count(CactiStubs::callsTo('cacti_snmp_send')); } /** @@ -93,7 +93,7 @@ public function trapCount() { * @return bool */ public function touchedLastChanged() { - foreach (CactiStub::callsTo('db_execute_prepared') as $call) { + foreach (CactiStubs::callsTo('db_execute_prepared') as $call) { if (strpos($call['sql'], 'lastchanged = NOW()') !== false) { return true; } @@ -108,7 +108,7 @@ public function touchedLastChanged() { * @return bool */ public function acknowledged() { - foreach (CactiStub::callsTo('db_execute_prepared') as $call) { + foreach (CactiStubs::callsTo('db_execute_prepared') as $call) { if (strpos($call['sql'], 'acknowledgment = "on"') !== false) { return true; } @@ -129,7 +129,7 @@ public function acknowledged() { public function persistedColumns() { $columns = []; - foreach (CactiStub::callsTo('db_execute_prepared') as $call) { + foreach (CactiStubs::callsTo('db_execute_prepared') as $call) { if (strpos($call['sql'], 'UPDATE thold_data') === false) { continue; } @@ -197,9 +197,9 @@ public function persistedFailCounts() { * @return bool */ public function isSilent() { - return $this->mailCount() === 0 - && $this->logStatuses() === [] - && $this->trapCount() === 0 - && CactiStub::callsTo('thold_command_execution') === []; + return $this->mailCount() === 0 + && $this->logStatuses() === [] + && $this->trapCount() === 0 + && CactiStubs::callsTo('thold_command_execution') === []; } } diff --git a/tests/Support/ThresholdScenario.php b/tests/Helpers/ThresholdScenario.php similarity index 92% rename from tests/Support/ThresholdScenario.php rename to tests/Helpers/ThresholdScenario.php index 7d01d361..8951078a 100644 --- a/tests/Support/ThresholdScenario.php +++ b/tests/Helpers/ThresholdScenario.php @@ -132,10 +132,10 @@ public static function threshold(array $overrides = []) { * The time-based arm multiplies this into a window bound; an empty * value is a fatal on PHP 8 rather than a missing step. */ - CactiStub::willReturnFor('db_fetch_cell_prepared', 'SELECT rrd_step', 300); + CactiStubs::willReturnFor('db_fetch_cell_prepared', 'SELECT rrd_step', 300); // Counts of prior log rows; the arms add these together arithmetically. - CactiStub::willReturnFor('db_fetch_cell_prepared', 'SELECT COUNT(id)', 0); + CactiStubs::willReturnFor('db_fetch_cell_prepared', 'SELECT COUNT(id)', 0); return $scenario; } @@ -148,7 +148,7 @@ public static function threshold(array $overrides = []) { * @return self */ public function device(array $overrides = []) { - CactiStub::willReturnFor('db_fetch_row_prepared', 'FROM host WHERE id = ?', $overrides + [ + CactiStubs::willReturnFor('db_fetch_row_prepared', 'FROM host WHERE id = ?', $overrides + [ 'id' => 2, 'description' => 'core-switch-1', 'hostname' => '10.0.0.1', @@ -174,7 +174,7 @@ public function device(array $overrides = []) { * @return self */ public function alertRecipient($address) { - CactiStub::willReturnFor('db_fetch_assoc_prepared', 'FROM plugin_thold_contacts', [['data' => $address]]); + CactiStubs::willReturnFor('db_fetch_assoc_prepared', 'FROM plugin_thold_contacts', [['data' => $address]]); return $this; } @@ -186,7 +186,7 @@ public function alertRecipient($address) { * @return self */ public function option($name, $value) { - CactiStub::$configOptions[$name] = $value; + CactiStubs::$configOptions[$name] = $value; return $this; } @@ -198,8 +198,8 @@ public function option($name, $value) { */ public function inMaintenance() { // Asked more than once per poll, so a queued value would run out. - CactiStub::willAlwaysReturn('api_plugin_is_enabled', true); - CactiStub::willAlwaysReturn('plugin_maint_check_cacti_host', true); + CactiStubs::willAlwaysReturn('api_plugin_is_enabled', true); + CactiStubs::willAlwaysReturn('plugin_maint_check_cacti_host', true); /* * thold include_once()s the maint plugin when it reports enabled. The diff --git a/tests/Support/CactiStub.php b/tests/Support/CactiStub.php deleted file mode 100644 index 4b408d05..00000000 --- a/tests/Support/CactiStub.php +++ /dev/null @@ -1,214 +0,0 @@ -}> - */ - public static $calls = []; - - /** - * Queued return values, keyed by function name. Each call shifts one value - * off the front; an exhausted queue falls back to the type default. - * - * @var array> - */ - public static $returns = []; - - /** - * Return values chosen by a fragment of the SQL, keyed by function name. - * Each entry is [fragment, value]. Consulted before $returns. - * - * @var array> - */ - public static $matchedReturns = []; - - /** - * Values handed back on every call, keyed by function name. Consulted last. - * - * @var array - */ - public static $stickyReturns = []; - - /** - * Values handed back by the get_*_request_var() family, keyed by var name. - * - * @var array - */ - public static $requestVars = []; - - /** - * Values handed back by read_config_option(), keyed by option name. - * - * @var array - */ - public static $configOptions = []; - - /** - * Messages passed to cacti_log(), in order. - * - * @var array - */ - public static $log = []; - - /** - * Mail handed to Cacti's mailer(), in order. - * - * @var array - */ - public static $mail = []; - - /** - * Clear all recorded and programmed state. - * - * @return void - */ - public static function reset() { - self::$calls = []; - self::$returns = []; - self::$matchedReturns = []; - self::$stickyReturns = []; - self::$requestVars = []; - self::$configOptions = []; - self::$log = []; - self::$mail = []; - } - - /** - * Record one Cacti function call. - * - * @param string $fn Cacti function name. - * @param string $sql SQL text, or '' for non-query calls. - * @param array $params Bound parameters, if any. - * - * @return void - */ - public static function record($fn, $sql = '', array $params = []) { - self::$calls[] = ['fn' => $fn, 'sql' => $sql, 'params' => $params]; - } - - /** - * Hand back $value for every call to $fn. - * - * @param string $fn Cacti function name. - * @param mixed $value Value to hand back. - * - * @return void - */ - public static function willAlwaysReturn($fn, $value) { - self::$stickyReturns[$fn] = $value; - } - - /** - * Queue one return value for the next call to $fn. - * - * @param string $fn Cacti function name. - * @param mixed $value Value to hand back. - * - * @return void - */ - public static function willReturn($fn, $value) { - self::$returns[$fn][] = $value; - } - - /** - * Answer any call to $fn whose SQL contains $fragment with $value. - * - * A function such as db_fetch_cell_prepared is called many times with - * different queries in one run, so a positional queue would break as soon - * as the code under test reordered a lookup. Matching on the query keeps - * the fixture readable and stable. - * - * @param string $fn Cacti function name. - * @param string $fragment Distinctive substring of the SQL. - * @param mixed $value Value to hand back. - * - * @return void - */ - public static function willReturnFor($fn, $fragment, $value) { - self::$matchedReturns[$fn][] = [$fragment, $value]; - } - - /** - * Take the return value for a call: a SQL match first, then the queue, then - * the type default. - * - * @param string $fn Cacti function name. - * @param mixed $default Fallback when nothing matches. - * @param string $sql SQL the caller passed, for matching. - * - * @return mixed - */ - public static function nextReturn($fn, $default, $sql = '') { - if ($sql !== '' && !empty(self::$matchedReturns[$fn])) { - $flat = preg_replace('/\s+/', ' ', $sql); - - foreach (self::$matchedReturns[$fn] as $entry) { - if (strpos($flat, preg_replace('/\s+/', ' ', $entry[0])) !== false) { - return $entry[1]; - } - } - } - - if (!empty(self::$returns[$fn])) { - return array_shift(self::$returns[$fn]); - } - - if (array_key_exists($fn, self::$stickyReturns)) { - return self::$stickyReturns[$fn]; - } - - return $default; - } - - /** - * All recorded calls to $fn. - * - * @param string $fn Cacti function name. - * - * @return array}> - */ - public static function callsTo($fn) { - return array_values(array_filter(self::$calls, function ($call) use ($fn) { - return $call['fn'] === $fn; - })); - } - - /** - * The recorded call log reduced to function names, in order. Useful for - * asserting transaction sequencing. - * - * @return array - */ - public static function callSequence() { - return array_column(self::$calls, 'fn'); - } -} diff --git a/tests/bootstrap.php b/tests/bootstrap.php deleted file mode 100644 index b5a39187..00000000 --- a/tests/bootstrap.php +++ /dev/null @@ -1,521 +0,0 @@ - dirname(dirname(dirname(__DIR__))), - 'url_path' => '/cacti/', - 'cacti_version' => '1.2.31', - 'cacti_server_os' => 'unix', -]; - -// thold_expand_string() include_once()s library_path/variables.php at call time. -$GLOBALS['config']['library_path'] = __DIR__ . '/fixtures/cacti-lib'; - -// thold reads and writes this on every RPN evaluation. -$GLOBALS['rpn_error'] = false; - -// Cacti's list of enabled plugins; thold_check_threshold() declares it global. -$GLOBALS['plugins'] = []; - -// Cacti's debug flag, also declared global by thold_check_threshold(). -$GLOBALS['debug'] = false; - -if (!function_exists('db_execute')) { - function db_execute($sql, $log = true, $db_conn = false) { - CactiStub::record('db_execute', $sql); - - return CactiStub::nextReturn('db_execute', true, $sql); - } -} - -if (!function_exists('db_execute_prepared')) { - function db_execute_prepared($sql, $params = [], $log = true, $db_conn = false) { - CactiStub::record('db_execute_prepared', $sql, $params); - - return CactiStub::nextReturn('db_execute_prepared', true, $sql); - } -} - -if (!function_exists('db_fetch_assoc')) { - function db_fetch_assoc($sql, $log = true, $db_conn = false) { - CactiStub::record('db_fetch_assoc', $sql); - - return CactiStub::nextReturn('db_fetch_assoc', [], $sql); - } -} - -if (!function_exists('db_fetch_assoc_prepared')) { - function db_fetch_assoc_prepared($sql, $params = [], $log = true, $db_conn = false) { - CactiStub::record('db_fetch_assoc_prepared', $sql, $params); - - return CactiStub::nextReturn('db_fetch_assoc_prepared', [], $sql); - } -} - -if (!function_exists('db_fetch_row')) { - function db_fetch_row($sql, $log = true, $db_conn = false) { - CactiStub::record('db_fetch_row', $sql); - - return CactiStub::nextReturn('db_fetch_row', [], $sql); - } -} - -if (!function_exists('db_fetch_row_prepared')) { - function db_fetch_row_prepared($sql, $params = [], $log = true, $db_conn = false) { - CactiStub::record('db_fetch_row_prepared', $sql, $params); - - return CactiStub::nextReturn('db_fetch_row_prepared', [], $sql); - } -} - -/* - * Cacti's cell fetchers return false, not '', when the query matches no row. - * The difference matters on PHP 8: false coerces to 0 in arithmetic while '' - * raises a TypeError, so a stub returning '' invents failures that production - * does not have. - */ -if (!function_exists('db_fetch_cell')) { - function db_fetch_cell($sql, $col_name = '', $log = true, $db_conn = false) { - CactiStub::record('db_fetch_cell', $sql); - - return CactiStub::nextReturn('db_fetch_cell', false, $sql); - } -} - -if (!function_exists('db_fetch_cell_prepared')) { - function db_fetch_cell_prepared($sql, $params = [], $col_name = '', $log = true, $db_conn = false) { - CactiStub::record('db_fetch_cell_prepared', $sql, $params); - - return CactiStub::nextReturn('db_fetch_cell_prepared', false, $sql); - } -} - -if (!function_exists('db_qstr')) { - function db_qstr($string) { - return "'" . str_replace("'", "''", (string) $string) . "'"; - } -} - -if (!function_exists('db_begin_transaction')) { - function db_begin_transaction() { - CactiStub::record('db_begin_transaction'); - - return CactiStub::nextReturn('db_begin_transaction', true); - } -} - -if (!function_exists('db_commit_transaction')) { - function db_commit_transaction() { - CactiStub::record('db_commit_transaction'); - - return CactiStub::nextReturn('db_commit_transaction', true); - } -} - -if (!function_exists('db_rollback_transaction')) { - function db_rollback_transaction() { - CactiStub::record('db_rollback_transaction'); - - return CactiStub::nextReturn('db_rollback_transaction', true); - } -} - -if (!function_exists('html_escape')) { - function html_escape($string) { - return htmlspecialchars((string) $string, ENT_QUOTES, 'UTF-8'); - } -} - -/* - * Mirrors Cacti 1.2 lib/functions.php. KEEP IN SYNC: if core tightens its - * checks, tests here would otherwise keep passing while production diverges. - */ -if (!function_exists('sanitize_unserialize_selected_items')) { - function sanitize_unserialize_selected_items($items) { - if (empty($items)) { - return false; - } - - $data = unserialize($items, ['allowed_classes' => false]); // nosemgrep: php.lang.security.unserialize-use.unserialize-use -- test stub mirroring Cacti core; allowed_classes:false blocks object injection - - if (!is_array($data)) { - return false; - } - - foreach ($data as $value) { - if (!is_numeric($value)) { - return false; - } - } - - return $data; - } -} - -if (!function_exists('read_config_option')) { - function read_config_option($name, $force = false) { - return isset(CactiStub::$configOptions[$name]) ? CactiStub::$configOptions[$name] : ''; - } -} - -if (!function_exists('set_config_option')) { - function set_config_option($name, $value) { - CactiStub::$configOptions[$name] = $value; - } -} - -if (!function_exists('__')) { - function __($text) { - $args = array_slice(func_get_args(), 1); - - // Cacti's __() accepts sprintf arguments after the format string. - return $args === [] ? $text : vsprintf($text, $args); - } -} - -if (!function_exists('__esc')) { - function __esc($text) { - return htmlspecialchars(call_user_func_array('__', func_get_args()), ENT_QUOTES, 'UTF-8'); - } -} - -if (!function_exists('cacti_log')) { - function cacti_log($message, $output = false, $environ = 'CMDPHP', $level = 0) { - CactiStub::$log[] = $message; - } -} - -if (!function_exists('cacti_sizeof')) { - function cacti_sizeof($array) { - return (is_array($array) || $array instanceof Countable) ? count($array) : 0; - } -} - -if (!function_exists('cacti_count')) { - function cacti_count($array) { - return cacti_sizeof($array); - } -} - -if (!function_exists('get_request_var')) { - function get_request_var($name, $default = '') { - return isset(CactiStub::$requestVars[$name]) ? CactiStub::$requestVars[$name] : $default; - } -} - -if (!function_exists('get_nfilter_request_var')) { - function get_nfilter_request_var($name, $default = '') { - return get_request_var($name, $default); - } -} - -if (!function_exists('get_filter_request_var')) { - function get_filter_request_var($name, $filter = FILTER_VALIDATE_INT, $options = []) { - return get_request_var($name); - } -} - -if (!function_exists('isset_request_var')) { - function isset_request_var($name) { - return isset(CactiStub::$requestVars[$name]); - } -} - -if (!function_exists('cacti_escapeshellarg')) { - function cacti_escapeshellarg($string, $quote = true) { - return escapeshellarg((string) $string); - } -} - -if (!function_exists('api_plugin_hook_function')) { - function api_plugin_hook_function($name, $data = '') { - return $data; - } -} - -if (!function_exists('get_simple_graph_perms')) { - function get_simple_graph_perms($user_id) { - return CactiStub::nextReturn('get_simple_graph_perms', true); - } -} - -if (!function_exists('get_policies')) { - function get_policies($user_id) { - return CactiStub::nextReturn('get_policies', []); - } -} - -if (!function_exists('get_policy_where')) { - function get_policy_where($graph_auth_method, $policies, $sql_where) { - CactiStub::record('get_policy_where', $sql_where); - - return CactiStub::nextReturn('get_policy_where', $sql_where); - } -} - -if (!function_exists('expand_title')) { - function expand_title($host_id, $snmp_query_id, $snmp_index, $title) { - CactiStub::record('expand_title', $title); - - return CactiStub::nextReturn('expand_title', $title); - } -} - -if (!function_exists('get_graph_title')) { - function get_graph_title($local_graph_id) { - return CactiStub::nextReturn('get_graph_title', 'Traffic - eth0'); - } -} - -if (!function_exists('rrdtool_function_fetch')) { - function rrdtool_function_fetch($local_data_id, $start_time, $end_time, $resolution = 0, $show_unknown = false, $rrdtool_file = null) { - CactiStub::record('rrdtool_function_fetch', (string) $local_data_id); - - return CactiStub::nextReturn('rrdtool_function_fetch', []); - } -} - -if (!function_exists('get_data_source_path')) { - function get_data_source_path($local_data_id, $expand_paths = true) { - return '/var/lib/cacti/rra/test_' . (int) $local_data_id . '.rrd'; - } -} - -if (!function_exists('sql_save')) { - function sql_save($array_items, $table_name, $key_cols = 'id', $autoinc = true, $db_conn = false) { - CactiStub::record('sql_save', $table_name, $array_items); - - return CactiStub::nextReturn('sql_save', 1); - } -} - -if (!function_exists('db_affected_rows')) { - function db_affected_rows($db_conn = false) { - return CactiStub::nextReturn('db_affected_rows', 1); - } -} - -if (!function_exists('rrdtool_function_graph')) { - function rrdtool_function_graph($local_graph_id, $rra_id, $graph_data_array, $rrdtool_pipe = false, &$xport_meta = [], $user = 0) { - CactiStub::record('rrdtool_function_graph', (string) $local_graph_id); - - // A one-pixel PNG stands in for the rendered graph. - return base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==', true); - } -} - -if (!function_exists('get_timespan')) { - function get_timespan(&$timespan, $time, $span, $first_weekdayid) { - $timespan['begin_now'] = $time - 86400; - $timespan['end_now'] = $time; - } -} - -if (!function_exists('read_user_setting')) { - function read_user_setting($config_name, $default = false, $force = false, $user = 0) { - return CactiStub::nextReturn('read_user_setting', $default); - } -} - -if (!function_exists('get_selected_theme')) { - function get_selected_theme() { - return 'modern'; - } -} - -if (!function_exists('mailer')) { - function mailer($from, $to, $cc = '', $bcc = '', $replyto = '', $subject = '', $body = '', $body_text = '', $attachments = null, $headers = [], $html = true) { - CactiStub::$mail[] = [ - 'to' => is_array($to) ? implode(',', $to) : (string) $to, - 'bcc' => is_array($bcc) ? implode(',', $bcc) : (string) $bcc, - 'subject' => (string) $subject, - ]; - - return CactiStub::nextReturn('mailer', ''); - } -} - -if (!function_exists('cacti_snmp_send')) { - function cacti_snmp_send($hostname, $version, $community, $oid, $value, $type = 's') { - CactiStub::record('cacti_snmp_send', (string) $oid); - - return true; - } -} - -if (!function_exists('array_rekey')) { - function array_rekey($array, $key, $key_value) { - $ret_array = []; - - if (is_array($array)) { - foreach ($array as $item) { - $item_key = $item[$key]; - - if (is_array($key_value)) { - foreach ($key_value as $value) { - $ret_array[$item_key][$value] = $item[$value]; - } - } else { - $ret_array[$item_key] = $item[$key_value]; - } - } - } - - return $ret_array; - } -} - -if (!function_exists('clean_up_name')) { - function clean_up_name($string) { - $string = preg_replace('/[\s\.]+/', '_', $string); - $string = preg_replace('/[^a-zA-Z0-9_]+/', '', $string); - - return preg_replace('/_{2,}/', '_', $string); - } -} - -if (!function_exists('plugin_maint_check_cacti_host')) { - function plugin_maint_check_cacti_host($host_id) { - return CactiStub::nextReturn('plugin_maint_check_cacti_host', false); - } -} - -if (!function_exists('api_plugin_is_enabled')) { - function api_plugin_is_enabled($plugin) { - return CactiStub::nextReturn('api_plugin_is_enabled', false); - } -} - -if (!function_exists('api_plugin_hook')) { - function api_plugin_hook($name, $data = '') { - CactiStub::record('api_plugin_hook', $name); - - return $data; - } -} - -if (!function_exists('api_user_realm_auth')) { - function api_user_realm_auth($filename = '') { - return CactiStub::nextReturn('api_user_realm_auth', true); - } -} - -if (!function_exists('raise_message')) { - function raise_message($message_id, $message = '', $level = 0) { - CactiStub::record('raise_message', (string) $message_id); - } -} - -if (!function_exists('rrdtool_execute')) { - function rrdtool_execute($command, $log_to_stdout = false, $output_flag = 1, $rrdtool_pipe = false, $logopt = 'WEBLOG') { - CactiStub::record('rrdtool_execute', $command); - - return CactiStub::nextReturn('rrdtool_execute', ''); - } -} - -if (!function_exists('rrdtool_function_interface_speed')) { - function rrdtool_function_interface_speed($data_local) { - return CactiStub::nextReturn('rrdtool_function_interface_speed', 0); - } -} - -if (!function_exists('get_timeinstate')) { - function get_timeinstate($host) { - return CactiStub::nextReturn('get_timeinstate', '1 day'); - } -} - -if (!function_exists('get_daysfromtime')) { - function get_daysfromtime($timestamp) { - return CactiStub::nextReturn('get_daysfromtime', '1 day'); - } -} - -if (!function_exists('number_format_i18n')) { - function number_format_i18n($number, $decimals = 0, $baseu = 1000) { - return number_format((float) $number, $decimals < 0 ? 0 : (int) $decimals); - } -} - -if (!defined('FILTER_VALIDATE_IS_REGEX')) { - define('FILTER_VALIDATE_IS_REGEX', 99999); -} - -// Device states, from Cacti include/global_constants.php. -foreach (['HOST_UNKNOWN' => 0, 'HOST_DOWN' => 1, 'HOST_RECOVERING' => 2, 'HOST_UP' => 3, 'HOST_ERROR' => 4] as $name => $value) { - if (!defined($name)) { - define($name, $value); - } -} - -if (!defined('RRDTOOL_OUTPUT_STDOUT')) { - define('RRDTOOL_OUTPUT_STDOUT', 1); -} - -if (!defined('CACTI_DATE_TIME_FORMAT')) { - define('CACTI_DATE_TIME_FORMAT', 'Y-m-d H:i:s'); -} - -if (!defined('CACTI_PATH_BASE')) { - define('CACTI_PATH_BASE', $GLOBALS['config']['base_path']); -} - -/** - * Load a plugin source file at global scope. - * - * Several plugin files (includes/arrays.php in particular) define their data - * as file-scope variables that the rest of the plugin reads as globals, and - * they read $config while doing so. Requiring them from inside a method would - * make both halves of that method-local, so the require happens here and any - * variable the file introduced is published to $GLOBALS. - * - * @param string $path Absolute path to the file. - * - * @return void - */ -function thold_test_load($path) { - global $config; - - $__before = get_defined_vars(); - - require_once $path; - - foreach (get_defined_vars() as $__name => $__value) { - if (!array_key_exists($__name, $__before) && strncmp($__name, '__', 2) !== 0) { - $GLOBALS[$__name] = $__value; - } - } -} diff --git a/tests/docker/Dockerfile b/tests/docker/Dockerfile index 5c0a414d..518f7321 100644 --- a/tests/docker/Dockerfile +++ b/tests/docker/Dockerfile @@ -26,4 +26,4 @@ ENV COMPOSER_ALLOW_SUPERUSER=1 \ COMPOSER_NO_INTERACTION=1 \ COMPOSER_CACHE_DIR=/tmp/composer-cache -CMD ["sh", "-c", "composer install --no-progress --no-ansi && vendor/bin/phpunit"] +CMD ["sh", "-c", "composer install --no-progress --no-ansi && composer test"] From e7d525ea8754d0759a5901b4f269ed2f2646c935 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 16 Aug 2026 20:53:52 -0700 Subject: [PATCH 04/11] test: pin the baseline threshold evaluator's behaviour This arm compares the reading against statistics rrdtool reports for a reference window, so the scenario supplies those rather than static bounds. Reaching them means answering the three rrdtool calls thold makes -- file existence, an info block describing the data sources and consolidation functions, and a graph command whose printed values are decoded by position -- which the helper now does. Completes the three arms, so Phase 2 can start moving code. Signed-off-by: Thomas Vincent --- tests/Helpers/ThresholdScenario.php | 58 ++++++ .../ThresholdBaselineCharacterizationTest.php | 186 ++++++++++++++++++ tests/bootstrap-unit.php | 11 +- 3 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/ThresholdBaselineCharacterizationTest.php diff --git a/tests/Helpers/ThresholdScenario.php b/tests/Helpers/ThresholdScenario.php index 8951078a..aa0e2b70 100644 --- a/tests/Helpers/ThresholdScenario.php +++ b/tests/Helpers/ThresholdScenario.php @@ -80,6 +80,7 @@ private function __construct(array $overrides) { 'bl_type' => 0, 'bl_cf' => 'AVG', 'bl_thold_valid' => 0, + 'cdef' => 0, 'notify_warning' => 0, 'notify_alert' => 0, @@ -191,6 +192,63 @@ public function option($name, $value) { return $this; } + /** + * Give the RRD a set of reference statistics for the baseline arm. + * + * thold reaches these through three rrdtool calls: file_exists, info to + * discover the data sources and consolidation functions, then a graph + * command whose PRINT output is decoded by position. The doubles below + * answer all three in the shapes that decode expects. + * + * @param float|int $average + * @param float|int $max + * @param float|int $min + * @param float|int $last + * @param string $dsname + * + * @return self + */ + public function referenceStatistics($average, $max, $min, $last, $dsname = 'traffic_in') { + /* + * With a storage location set, thold asks rrdtool whether the file + * exists rather than touching the filesystem, which keeps the fixture + * off disk. + */ + CactiStubs::$configOptions['storage_location'] = 1; + + CactiStubs::willReturnFor('db_fetch_cell_prepared', 'SELECT rrd_path', '/var/lib/cacti/rra/test.rrd'); + CactiStubs::willReturnFor('rrdtool_execute', 'file_exists', true); + + /* + * One rra line per consolidation function: the info parser sets a + * single flag per line, and the number of flags set has to match the + * number of values the graph command below prints. + */ + CactiStubs::willReturnFor('rrdtool_execute', 'info ', implode("\n", [ + 'ds[' . $dsname . '].type = "COUNTER"', + 'rra[0].cf = "AVERAGE"', + 'rra[1].cf = "MAX"', + 'rra[2].cf = "MIN"', + 'rra[3].cf = "LAST"', + 'step = 300', + ])); + + /* + * First line is the graph size and is skipped; then one value per + * PRINT in AVG, MAX, MIN, LAST order; then the timing line. + */ + CactiStubs::willReturnFor('rrdtool_execute', 'graph x --start', implode("\n", [ + '0x0', + (string) $average, + (string) $max, + (string) $min, + (string) $last, + 'OK u:0.01 s:0.00 r:0.01', + ])); + + return $this; + } + /** * Put the device into a maintenance window. * diff --git a/tests/Unit/ThresholdBaselineCharacterizationTest.php b/tests/Unit/ThresholdBaselineCharacterizationTest.php new file mode 100644 index 00000000..a510eaf0 --- /dev/null +++ b/tests/Unit/ThresholdBaselineCharacterizationTest.php @@ -0,0 +1,186 @@ + $overrides + * + * @return ThresholdScenario + */ + private function baseline(array $overrides = []) { + return ThresholdScenario::threshold($overrides + [ + 'thold_type' => 1, + 'bl_type' => 0, + 'bl_pct_up' => 10, + 'bl_pct_down' => 10, + 'bl_ref_time_range' => 3600, + 'bl_fail_trigger' => 1, + ]) + ->alertRecipient('ops@example.org') + ->referenceStatistics(100, 100, 100, 100); + } + + /** + * @return void + */ + public function testReadingInsideTheBandEmitsNothing(): void { + $outcome = $this->baseline(['lastread' => 100])->poll(); + + $this->assertTrue($outcome->isSilent()); + $this->assertSame(0, $outcome->thold['bl_alert']); + } + + /** + * @return void + */ + public function testReadingAboveTheBandAlerts(): void { + $outcome = $this->baseline(['lastread' => 500])->poll(); + + $this->assertSame(STAT_HI, $outcome->thold['bl_alert']); + $this->assertSame([ST_NOTIFYAL], $outcome->logStatuses()); + $this->assertSame(1, $outcome->mailCount()); + } + + /** + * @return void + */ + public function testReadingBelowTheBandAlerts(): void { + $outcome = $this->baseline(['lastread' => 1])->poll(); + + $this->assertSame(STAT_LO, $outcome->thold['bl_alert']); + $this->assertSame([ST_NOTIFYAL], $outcome->logStatuses()); + $this->assertSame(1, $outcome->mailCount()); + } + + /** + * @return void + */ + public function testReturningToTheBandNotifiesTheRestoral(): void { + $outcome = $this->baseline([ + 'lastread' => 100, + 'bl_alert' => STAT_HI, + 'bl_fail_count' => 3, + ])->poll(); + + $this->assertSame(0, $outcome->thold['bl_alert']); + $this->assertSame([ST_RESTORAL], $outcome->logStatuses()); + $this->assertSame(1, $outcome->mailCount()); + } + + /** + * When rrdtool returns no reference statistics the arm cannot decide + * anything, so it reports -1 and leaves the threshold alone. This is the + * state a newly created baseline threshold sits in until its reference + * window has filled. + * + * @return void + */ + public function testMissingReferenceStatisticsEmitsNothing(): void { + $outcome = ThresholdScenario::threshold([ + 'thold_type' => 1, + 'bl_type' => 0, + 'bl_pct_up' => 10, + 'bl_pct_down' => 10, + 'lastread' => 50, + ])->alertRecipient('ops@example.org')->poll(); + + $this->assertSame(-1, $outcome->thold['bl_alert']); + $this->assertTrue($outcome->isSilent()); + } + + /** + * @return void + */ + public function testBreachBelowTheTriggerDoesNotNotify(): void { + $outcome = $this->baseline([ + 'lastread' => 500, + 'bl_fail_trigger' => 3, + 'bl_fail_count' => 0, + ])->poll(); + + $this->assertSame(0, $outcome->mailCount()); + } + + /** + * @return void + */ + public function testMaintenanceWindowSuppressesNotification(): void { + $outcome = $this->baseline(['lastread' => 500])->inMaintenance()->poll(); + + $this->assertSame(0, $outcome->mailCount()); + } + + /** + * @return void + */ + public function testAcknowledgedThresholdDoesNotMailOnBreach(): void { + $outcome = $this->baseline([ + 'lastread' => 500, + 'acknowledgment' => 'on', + ])->poll(); + + $this->assertSame(0, $outcome->mailCount()); + } + + /** + * An absolute-deviation baseline adds the configured amount to the + * reference rather than a percentage of it, so 100 with a band of 10 gives + * the same 90 to 110 range for a very different configuration. + * + * @return void + */ + public function testAbsoluteDeviationUsesTheBandAsAnAmount(): void { + $inside = $this->baseline([ + 'bl_type' => 2, + 'lastread' => 105, + 'bl_pct_up' => 10, + 'bl_pct_down' => 10, + ])->poll(); + + $this->assertSame(0, $inside->thold['bl_alert']); + } + + /** + * @return void + */ + public function testAbsoluteDeviationAlertsOutsideTheAmount(): void { + $outcome = $this->baseline([ + 'bl_type' => 2, + 'lastread' => 200, + 'bl_pct_up' => 10, + 'bl_pct_down' => 10, + ])->poll(); + + $this->assertSame(STAT_HI, $outcome->thold['bl_alert']); + } +} diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index ce2b2615..8f0272ee 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -482,7 +482,7 @@ function raise_message($message_id, $message = '', $level = 0) { function rrdtool_execute($command, $log_to_stdout = false, $output_flag = 1, $rrdtool_pipe = false, $logopt = 'WEBLOG') { CactiStubs::record('rrdtool_execute', $command); - return CactiStubs::nextReturn('rrdtool_execute', ''); + return CactiStubs::nextReturn('rrdtool_execute', '', $command); } } @@ -521,8 +521,13 @@ function number_format_i18n($number, $decimals = 0, $baseu = 1000) { } } -if (!defined('RRDTOOL_OUTPUT_STDOUT')) { - define('RRDTOOL_OUTPUT_STDOUT', 1); +// rrdtool output modes, from Cacti include/global_constants.php. +foreach (['RRDTOOL_OUTPUT_NULL' => 0, 'RRDTOOL_OUTPUT_STDOUT' => 1, 'RRDTOOL_OUTPUT_STDERR' => 2, + 'RRDTOOL_OUTPUT_GRAPH_DATA' => 3, 'RRDTOOL_OUTPUT_BOOLEAN' => 4, + 'RRDTOOL_OUTPUT_RETURN_STDERR' => 5] as $name => $value) { + if (!defined($name)) { + define($name, $value); + } } if (!defined('CACTI_DATE_TIME_FORMAT')) { From b4f9554b84ce4c6c6ff3f4c7d86aeeb3775553b1 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 16 Aug 2026 21:01:30 -0700 Subject: [PATCH 05/11] perf(thold): drop eight write-only locals from the threshold evaluator Each was assigned and never read. One of them, $baseu, ran a query against graph_templates_graph and discarded the result, so every threshold on every poll cycle paid for a round trip that fed nothing. $show_datasource likewise called thold_datasource_required() for a value nobody used. The others are $global_alert_address, $deadnotify (issue #751), $suffix, $show_units, $units_suffix and $decimals. Signed-off-by: Thomas Vincent --- thold_functions.php | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/thold_functions.php b/thold_functions.php index 018cf99b..d8d35b12 100644 --- a/thold_functions.php +++ b/thold_functions.php @@ -2250,9 +2250,6 @@ function thold_check_threshold(&$thold_data) { // ensure that Cacti will make of individual defined SNMP Engine IDs $overwrite['snmp_engine_id'] = $h['snmp_engine_id']; - // pull a few default settings - $global_alert_address = read_config_option('alert_email'); - // Settings for syslogging $syslog = $thold_data['syslog_enabled'] == 'on' ? true : false; $syslog_priority = $thold_data['syslog_priority']; @@ -2269,9 +2266,6 @@ function thold_check_threshold(&$thold_data) { $thold_snmp_normal_traps = (read_config_option('thold_alert_snmp_normal') == 'on'); $cacti_polling_interval = read_config_option('poller_interval'); - // remove this after adding an option for it - $show_datasource = thold_datasource_required(thold_get_cached_name($thold_data), $thold_data['data_source_name']); - $trigger = ($thold_data['thold_fail_trigger'] == '' ? $alert_trigger : $thold_data['thold_fail_trigger']); $warning_trigger = ($thold_data['thold_warning_fail_trigger'] == '' ? $alert_trigger : $thold_data['thold_warning_fail_trigger']); $alertstat = $thold_data['thold_alert']; @@ -2287,22 +2281,6 @@ function thold_check_threshold(&$thold_data) { } } - // setup base units - $baseu = db_fetch_cell_prepared('SELECT base_value - FROM graph_templates_graph - WHERE local_graph_id = ?', - [$thold_data['local_graph_id']]); - - if ($thold_data['data_type'] == 2) { - $suffix = false; - } else { - $suffix = true; - } - - $show_units = ($thold_data['show_units'] ? true : false); - $units_suffix = $thold_data['units_suffix']; - $decimals = $thold_data['decimals'] >= 0 ? $thold_data['decimals'] : 2; - $file_array = []; if ($thold_send_text_only != 'on') { From 0a650ed975c273776faa631611d801122e6e1112 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 16 Aug 2026 21:05:00 -0700 Subject: [PATCH 06/11] refactor(thold): lift the evaluation settings out of the threshold check The preamble computed the settings the three threshold arms read, so reaching any of that logic meant driving a whole poll. It moves to thold_evaluation_context(), which derives everything from the threshold row and the Cacti settings and decides nothing, and the check unpacks what it needs. Pure motion. The characterization tests from the previous commits pass unchanged, which is what they were written for, and the rules that were buried in the preamble now have direct tests: trigger fallback, whether alerts also reach the warning recipients, and when a graph is attached. thold_check_threshold() goes from 1432 lines to 1378. Signed-off-by: Thomas Vincent --- tests/Unit/TholdEvaluationContextTest.php | 206 ++++++++++++++++++++++ thold_functions.php | 140 +++++++++------ 2 files changed, 293 insertions(+), 53 deletions(-) create mode 100644 tests/Unit/TholdEvaluationContextTest.php diff --git a/tests/Unit/TholdEvaluationContextTest.php b/tests/Unit/TholdEvaluationContextTest.php new file mode 100644 index 00000000..758ee811 --- /dev/null +++ b/tests/Unit/TholdEvaluationContextTest.php @@ -0,0 +1,206 @@ + $overrides + * + * @return array + */ + private function context(array $overrides = []) { + return thold_evaluation_context($overrides + [ + 'id' => 1, + 'name' => 'CPU', + 'name_cache' => 'CPU', + 'local_graph_id' => 7, + 'local_data_id' => 4, + 'data_source_name' => 'traffic_in', + 'lastread' => 50, + 'thold_alert' => 0, + 'thold_fail_trigger' => 3, + 'thold_warning_fail_trigger' => 2, + 'notify_alert' => 0, + 'notify_warning' => 0, + 'notify_extra' => '', + 'notify_warning_extra' => '', + 'syslog_enabled' => '', + 'syslog_priority' => 5, + 'syslog_facility' => 1, + ]); + } + + /** + * @return void + */ + public function testThresholdTriggersAreUsedWhenSet(): void { + $context = $this->context(); + + $this->assertSame(3, $context['trigger']); + $this->assertSame(2, $context['warning_trigger']); + } + + /** + * @return void + */ + public function testUnsetTriggersFallBackToTheGlobalDefault(): void { + CactiStubs::$configOptions['alert_trigger'] = 5; + + $context = $this->context([ + 'thold_fail_trigger' => '', + 'thold_warning_fail_trigger' => '', + ]); + + $this->assertSame(5, $context['trigger']); + $this->assertSame(5, $context['warning_trigger']); + } + + /** + * @return void + */ + public function testSyslogIsOffUnlessTheThresholdEnablesIt(): void { + $this->assertFalse($this->context()['syslog']); + $this->assertTrue($this->context(['syslog_enabled' => 'on'])['syslog']); + } + + /** + * @return array + */ + public static function trapSettingProvider() { + return [ + 'alert' => ['thold_alert_snmp', 'thold_snmp_traps'], + 'warning' => ['thold_alert_snmp_warning', 'thold_snmp_warning_traps'], + 'normal' => ['thold_alert_snmp_normal', 'thold_snmp_normal_traps'], + ]; + } + + /** + * @dataProvider trapSettingProvider + * + * @param string $option + * @param string $key + * + * @return void + */ + public function testEachTrapClassFollowsItsOwnSetting($option, $key): void { + $this->assertFalse($this->context()[$key]); + + CactiStubs::$configOptions[$option] = 'on'; + + $this->assertTrue($this->context()[$key]); + } + + /** + * Alerts go to the warning recipients as well only when the two lists + * differ and the operator has asked for it. + * + * @return void + */ + public function testWarningRecipientsAreAddedOnlyWhenConfiguredAndDistinct(): void { + CactiStubs::$configOptions['thold_notify_alerts_to_warning_recipients'] = 'on'; + + $this->assertTrue($this->context(['notify_alert' => 1, 'notify_warning' => 2])['notify_different']); + $this->assertFalse($this->context(['notify_alert' => 2, 'notify_warning' => 2])['notify_different']); + $this->assertFalse($this->context(['notify_alert' => 1, 'notify_warning' => 0])['notify_different']); + } + + /** + * @return void + */ + public function testWarningRecipientsAreNotAddedWhenTheOptionIsOff(): void { + $this->assertFalse($this->context(['notify_alert' => 1, 'notify_warning' => 2])['notify_different']); + } + + /** + * @return void + */ + public function testAGraphIsAttachedByDefault(): void { + $file_array = $this->context()['file_array']; + + $this->assertSame(7, $file_array['local_graph_id']); + $this->assertSame('image/png', $file_array['mimetype']); + } + + /** + * @return void + */ + public function testTextOnlyNotificationsAttachNoGraph(): void { + CactiStubs::$configOptions['thold_send_text_only'] = 'on'; + + $this->assertSame([], $this->context()['file_array']); + } + + /** + * @return void + */ + public function testNoGraphIsAttachedWhenTheThresholdHasNone(): void { + $this->assertSame([], $this->context(['local_graph_id' => 0])['file_array']); + } + + /** + * @return void + */ + public function testTheGraphUrlPointsAtTheThresholdsGraph(): void { + CactiStubs::$configOptions['base_url'] = 'http://cacti.example.org'; + + $this->assertSame( + 'http://cacti.example.org/graph.php?local_graph_id=7&rra_id=all', + $this->context()['url'] + ); + } + + /** + * @return void + */ + public function testRecipientsAreResolvedForBothClasses(): void { + CactiStubs::willReturnFor('db_fetch_assoc_prepared', 'FROM plugin_thold_contacts', [['data' => 'ops@example.org']]); + + $context = $this->context(); + + $this->assertStringContainsString('ops@example.org', $context['alert_emails']); + $this->assertArrayHasKey('warning_emails', $context); + $this->assertArrayHasKey('alert_bcc_emails', $context); + $this->assertArrayHasKey('warning_bcc_emails', $context); + } + + /** + * @return void + */ + public function testTheCurrentReadingIsCarriedThrough(): void { + $this->assertSame(95, $this->context(['lastread' => 95])['lastread']); + } + + /** + * @return void + */ + public function testThePreviousAlertStateIsCarriedThrough(): void { + $this->assertSame(2, $this->context(['thold_alert' => 2])['alertstat']); + } +} diff --git a/thold_functions.php b/thold_functions.php index d8d35b12..2911e8ce 100644 --- a/thold_functions.php +++ b/thold_functions.php @@ -2171,6 +2171,71 @@ function thold_datasource_required($name, $data_source) { return true; } +/** + * Gather the settings a threshold evaluation reads, in one place. + * + * Everything here is derived from the threshold row and the Cacti settings; it + * does not decide anything and has no side effects, which is what lets it move + * out of thold_check_threshold() without changing behaviour. + * + * @param array $thold_data Threshold row. + * + * @return array + */ +function thold_evaluation_context(array $thold_data) { + $alert_trigger = read_config_option('alert_trigger'); + $httpurl = read_config_option('base_url'); + $thold_send_text_only = read_config_option('thold_send_text_only'); + + // see if we have two notification lists or one + $notify_different = $thold_data['notify_warning'] > 0 + && $thold_data['notify_warning'] != $thold_data['notify_alert'] + && read_config_option('thold_notify_alerts_to_warning_recipients') == 'on'; + + $file_array = []; + + if ($thold_send_text_only != 'on' && !empty($thold_data['local_graph_id'])) { + $file_array = [ + 'local_graph_id' => $thold_data['local_graph_id'], + 'local_data_id' => $thold_data['local_data_id'], + 'rra_id' => 0, + 'file' => "$httpurl/graph_image.php?local_graph_id=" . $thold_data['local_graph_id'] . '&rra_id=0&view_type=tree', + 'mimetype' => 'image/png', + 'filename' => clean_up_name(thold_get_cached_name($thold_data)) + ]; + } + + return [ + // Settings for syslogging + 'syslog' => $thold_data['syslog_enabled'] == 'on', + 'syslog_priority' => $thold_data['syslog_priority'], + 'syslog_facility' => $thold_data['syslog_facility'], + + 'realert' => read_config_option('alert_repeat'), + 'alert_bl_trigger' => read_config_option('alert_bl_trigger'), + + 'thold_snmp_traps' => read_config_option('thold_alert_snmp') == 'on', + 'thold_snmp_warning_traps' => read_config_option('thold_alert_snmp_warning') == 'on', + 'thold_snmp_normal_traps' => read_config_option('thold_alert_snmp_normal') == 'on', + 'cacti_polling_interval' => read_config_option('poller_interval'), + + // An unset trigger on the threshold falls back to the global default. + 'trigger' => $thold_data['thold_fail_trigger'] == '' ? $alert_trigger : $thold_data['thold_fail_trigger'], + 'warning_trigger' => $thold_data['thold_warning_fail_trigger'] == '' ? $alert_trigger : $thold_data['thold_warning_fail_trigger'], + 'alertstat' => $thold_data['thold_alert'], + + 'notify_different' => $notify_different, + 'file_array' => $file_array, + 'url' => $httpurl . '/graph.php?local_graph_id=' . $thold_data['local_graph_id'] . '&rra_id=all', + 'lastread' => $thold_data['lastread'], + + 'alert_emails' => get_thold_emails($thold_data, 'alert', 'to'), + 'alert_bcc_emails' => get_thold_emails($thold_data, 'alert', 'bcc'), + 'warning_emails' => get_thold_emails($thold_data, 'warning', 'to'), + 'warning_bcc_emails' => get_thold_emails($thold_data, 'warning', 'bcc'), + ]; +} + function thold_check_threshold(&$thold_data) { global $config, $plugins, $debug; @@ -2250,59 +2315,28 @@ function thold_check_threshold(&$thold_data) { // ensure that Cacti will make of individual defined SNMP Engine IDs $overwrite['snmp_engine_id'] = $h['snmp_engine_id']; - // Settings for syslogging - $syslog = $thold_data['syslog_enabled'] == 'on' ? true : false; - $syslog_priority = $thold_data['syslog_priority']; - $syslog_facility = $thold_data['syslog_facility']; - - $realert = read_config_option('alert_repeat'); - $alert_trigger = read_config_option('alert_trigger'); - $alert_bl_trigger = read_config_option('alert_bl_trigger'); - $httpurl = read_config_option('base_url'); - $thold_send_text_only = read_config_option('thold_send_text_only'); - - $thold_snmp_traps = (read_config_option('thold_alert_snmp') == 'on'); - $thold_snmp_warning_traps = (read_config_option('thold_alert_snmp_warning') == 'on'); - $thold_snmp_normal_traps = (read_config_option('thold_alert_snmp_normal') == 'on'); - $cacti_polling_interval = read_config_option('poller_interval'); - - $trigger = ($thold_data['thold_fail_trigger'] == '' ? $alert_trigger : $thold_data['thold_fail_trigger']); - $warning_trigger = ($thold_data['thold_warning_fail_trigger'] == '' ? $alert_trigger : $thold_data['thold_warning_fail_trigger']); - $alertstat = $thold_data['thold_alert']; - - // see if we have two notification lists or one - $notify_different = false; - - if ($thold_data['notify_warning'] > 0) { - if ($thold_data['notify_warning'] != $thold_data['notify_alert']) { - if (read_config_option('thold_notify_alerts_to_warning_recipients') == 'on') { - $notify_different = true; - } - } - } - - $file_array = []; - - if ($thold_send_text_only != 'on') { - if (!empty($thold_data['local_graph_id'])) { - $file_array = [ - 'local_graph_id' => $thold_data['local_graph_id'], - 'local_data_id' => $thold_data['local_data_id'], - 'rra_id' => 0, - 'file' => "$httpurl/graph_image.php?local_graph_id=" . $thold_data['local_graph_id'] . '&rra_id=0&view_type=tree', - 'mimetype' => 'image/png', - 'filename' => clean_up_name(thold_get_cached_name($thold_data)) - ]; - } - } - - $url = $httpurl . '/graph.php?local_graph_id=' . $thold_data['local_graph_id'] . '&rra_id=all'; - $lastread = $thold_data['lastread']; - - $alert_emails = get_thold_emails($thold_data, 'alert', 'to'); - $alert_bcc_emails = get_thold_emails($thold_data, 'alert', 'bcc'); - $warning_emails = get_thold_emails($thold_data, 'warning', 'to'); - $warning_bcc_emails = get_thold_emails($thold_data, 'warning', 'bcc'); + $context = thold_evaluation_context($thold_data); + + $syslog = $context['syslog']; + $syslog_priority = $context['syslog_priority']; + $syslog_facility = $context['syslog_facility']; + $realert = $context['realert']; + $alert_bl_trigger = $context['alert_bl_trigger']; + $thold_snmp_traps = $context['thold_snmp_traps']; + $thold_snmp_warning_traps = $context['thold_snmp_warning_traps']; + $thold_snmp_normal_traps = $context['thold_snmp_normal_traps']; + $cacti_polling_interval = $context['cacti_polling_interval']; + $trigger = $context['trigger']; + $warning_trigger = $context['warning_trigger']; + $alertstat = $context['alertstat']; + $notify_different = $context['notify_different']; + $file_array = $context['file_array']; + $url = $context['url']; + $lastread = $context['lastread']; + $alert_emails = $context['alert_emails']; + $alert_bcc_emails = $context['alert_bcc_emails']; + $warning_emails = $context['warning_emails']; + $warning_bcc_emails = $context['warning_bcc_emails']; switch ($thold_data['thold_type']) { case 0: // hi/low From f4435198427e4d2f35e7156e20fe7f9d812428b4 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Mon, 17 Aug 2026 14:22:08 -0700 Subject: [PATCH 07/11] test: load branch helpers without plugin Composer autoload --- tests/bootstrap-unit.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index 8f0272ee..314f01b1 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -62,6 +62,8 @@ require_once $autoload; require_once __DIR__ . '/Helpers/CactiStubs.php'; require_once __DIR__ . '/TestCase.php'; +require_once __DIR__ . '/Helpers/ThresholdOutcome.php'; +require_once __DIR__ . '/Helpers/ThresholdScenario.php'; /* * base_path has to point at the Cacti root two levels above this plugin: From 60ed4181e7f67d82aa7414e741a30632f69b5fac Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Mon, 17 Aug 2026 14:47:13 -0700 Subject: [PATCH 08/11] ci: keep plugin PR integration checks on pinned Cacti --- .github/workflows/plugin-ci-workflow.yml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 5e4f3db6..79b16b7b 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -35,21 +35,12 @@ jobs: integration-test: runs-on: ${{ matrix.os }} - # A failure against the pinned release is a real failure. The develop entry - # is advisory: it is how a core regression becomes visible here, but it must - # not turn the plugin's own pull requests red. - continue-on-error: ${{ matrix.cacti != 'release/1.2.31' }} - strategy: fail-fast: false matrix: php: ['8.1', '8.2', '8.3', '8.4'] os: [ubuntu-latest] cacti: ['release/1.2.31'] - include: - - php: '8.4' - os: ubuntu-latest - cacti: 'develop' services: mariadb: From a024d4a5a24b4b9466f618ddbe1d5966b0b6e2f4 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Mon, 17 Aug 2026 14:50:04 -0700 Subject: [PATCH 09/11] test: tighten characterization fixture metadata and permissions --- tests/Helpers/CactiStubs.php | 6 +++--- tests/Helpers/ThresholdScenario.php | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Helpers/CactiStubs.php b/tests/Helpers/CactiStubs.php index 59d084cf..b7515c9e 100644 --- a/tests/Helpers/CactiStubs.php +++ b/tests/Helpers/CactiStubs.php @@ -31,7 +31,7 @@ final class CactiStubs { /** * Every Cacti function call the plugin made, in order. * - * @var array}> + * @var array}> */ public static $calls = []; @@ -107,7 +107,7 @@ public static function reset() { * * @param string $fn Cacti function name. * @param string $sql SQL text, or '' for non-query calls. - * @param array $params Bound parameters, if any. + * @param array $params Bound parameters, if any. * * @return void */ @@ -197,7 +197,7 @@ public static function nextReturn($fn, $default, $sql = '') { * * @param string $fn Cacti function name. * - * @return array}> + * @return array}> */ public static function callsTo($fn) { return array_values(array_filter(self::$calls, function ($call) use ($fn) { diff --git a/tests/Helpers/ThresholdScenario.php b/tests/Helpers/ThresholdScenario.php index aa0e2b70..de3fc6d4 100644 --- a/tests/Helpers/ThresholdScenario.php +++ b/tests/Helpers/ThresholdScenario.php @@ -267,7 +267,7 @@ public function inMaintenance() { $maint = dirname(__DIR__, 3) . '/maint'; if (!is_dir($maint)) { - mkdir($maint, 0777, true); + mkdir($maint, 0755, true); } if (!file_exists($maint . '/functions.php')) { From 97f802edfac32838732dadbafe561034bf91988f Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Mon, 17 Aug 2026 14:50:26 -0700 Subject: [PATCH 10/11] test: match the optional Cacti database stub signature --- tests/bootstrap-unit.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index 314f01b1..6c4a8e4f 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -159,7 +159,7 @@ function db_fetch_cell_prepared($sql, $params = [], $col_name = '', $log = true, } if (!function_exists('db_qstr')) { - function db_qstr($string) { + function db_qstr($string, $db_conn = false) { return "'" . str_replace("'", "''", (string) $string) . "'"; } } From 48be03f759cc686bc75fc9440c09dfae31df6148 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Mon, 17 Aug 2026 14:59:50 -0700 Subject: [PATCH 11/11] ci: bound package index refreshes --- .github/workflows/plugin-ci-workflow.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 79b16b7b..e17554e8 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -86,7 +86,24 @@ jobs: echo "PHP_BINARY=$(command -v php)" >> "$GITHUB_ENV" - name: Run apt-get update - run: sudo apt-get update + run: | + for attempt in 1 2 3; do + if sudo timeout 3m apt-get \ + -o Dpkg::Lock::Timeout=60 \ + -o Acquire::Retries=3 \ + -o Acquire::http::Timeout=30 \ + -o Acquire::https::Timeout=30 \ + update; then + exit 0 + fi + + if [ "$attempt" -lt 3 ]; then + sleep 10 + fi + done + + echo 'apt-get update failed after three bounded attempts.' >&2 + exit 1 - name: Install System Dependencies run: sudo apt-get install -y apache2 snmp snmpd rrdtool fping