From 3afd0621af806b9ba79884b9a7e8bcf457f7fb15 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 16 Aug 2026 20:04:33 -0700 Subject: [PATCH 1/8] 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 b0aba8302f2732a1c81e28099bc641ec0c9f1fc8 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 16 Aug 2026 20:07:26 -0700 Subject: [PATCH 2/8] 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 d3832c400f934622beb12bb43d330330198a6efe Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 16 Aug 2026 20:17:09 -0700 Subject: [PATCH 3/8] 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 d52fa4cec37b85388c53f02a41b3a219f0fd165e Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 16 Aug 2026 20:53:52 -0700 Subject: [PATCH 4/8] 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 8a0865b6da49de2fa421f4320cb753e2a8fd350a Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Mon, 17 Aug 2026 14:22:52 -0700 Subject: [PATCH 5/8] 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 ca3e1df9437847a25aa2927ba20aeab970c7bba6 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Mon, 17 Aug 2026 14:47:13 -0700 Subject: [PATCH 6/8] 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 0ea776aeef9a2ad34b3d9295aae8a858e25fcd4b Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Mon, 17 Aug 2026 14:50:04 -0700 Subject: [PATCH 7/8] 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 c0069784cfdd691346f955f6a0129e72b47529c0 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Mon, 17 Aug 2026 14:59:50 -0700 Subject: [PATCH 8/8] 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