Files
Cactiplugin_thold/tests/Unit/TholdGetCurrentvalTest.php
TheWitnessandCopilot 9b217e0e3a INFO: set compat to 1.2.29 (#838)
* INFO: set compat to 1.2.32

* docs: update Copilot instructions Cacti baseline to 1.2.32 (matches INFO compat)

* INFO: back off compat to 1.2.29

* Add manifest.json + upgrade-time file pruning

* Prune phpunit.xml on upgrade and ignore markdown-lint configs

phpunit.xml ships in the repo for CI but is a dev-only test artifact, so it
is removed from manifest.json 'expected', pruned from installs on upgrade
(like tests/), and excluded from the manifest drift check.

The retired markdown-lint configs (.mdlrc, .md_style.rb) are deleted, and
.md* is now ignored like .git*: protected from pruning and excluded from
drift if it reappears.

* Move thold_functions.php and thold_webapi.php into includes/; flatten css

The main threshold library becomes includes/functions.php and the web API
helper becomes includes/webapi.php; every caller (setup.php, includes/*, the
CLI/poller/notify entry points and the test suite) loads them from includes/.
Each per-theme stylesheet css/<theme>/main.css becomes css/<theme>.css so users
can drop their own css/<theme>.css into the css base, and the page_head hook
looks there. Old paths are tombstoned so existing installs drop the stale
copies on upgrade.

* Regenerate cacti.pot for the includes/ file relocations

Moving the plugin's library files into includes/ changed the source-reference
lines in the translation template; regenerate it so the 'Verify translation
template' CI check passes.

* Harden the hook-include test and make RPN integer ops explicit

The HookFunctionsIncludeTest invokes real hooks with placeholder input purely
to execute their relocated include_once lines; it now swallows engine
notices/warnings/deprecations during those calls (and restores the error
handler and config afterward) so it cannot trip the suite's failOnDeprecation
or leak state into other tests. The RPN % and ^ operators now cast their
operands to int explicitly, matching their long-standing integer-truncation
behaviour while silencing the implicit float-to-int conversion deprecation.

* Harden prune against tampered-manifest path traversal

The upgrade-time prune now rejects any tombstone containing '.'/'..' segments
(which could escape the plugin directory or resolve to its root) and treats
ancestors of whitelist entries as protected, so a tombstone on a parent
directory can no longer delete a whitelisted file beneath it.

* docs: update and align the Project Structure block

Point the structure tree at the relocated files (libraries now under
includes/, data files under docs/, stylesheets under css/) and align the
trailing '# ...' comments to a single column so they no longer drift right.

* docs/tests: standardize test header and correct manifest-contract notes

- Use the full license header (from the plugin's own setup.php) in
  tests/Unit/PruneFilesTest.php instead of the abbreviated copyright banner.
- Document that the manifest drift check and the upgrade-time prune also
  handle phpunit.xml and .md* files, matching the implemented behavior.

* refactor: rename prune helpers to the plugin-slug function prefix

The repository naming contract reserves the plugin_<name>_ prefix for plugin
lifecycle / hook-registration functions; all other functions use the plain
<name>_ prefix. Rename the internal upgrade helpers accordingly:

  plugin_<name>_prune_files() -> <name>_prune_files()
  plugin_<name>_rmtree()      -> <name>_rmtree()

The call site, unit tests, and the copilot-instructions.md references are
updated to match. No behavioral change.

* chore: set the Cacti compatibility floor to 1.2.29

Align the compat metadata (and the contributor-guide references) with the
team decision to standardize the minimum supported Cacti version at 1.2.29;
the previously proposed 1.2.32 floor was not adopted.

---------

Co-authored-by: Copilot <copilot@users.noreply.github.com>
2026-10-01 01:40:00 -05:00

856 lines
30 KiB
PHP

<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
| http://www.cacti.net/ |
+-------------------------------------------------------------------------+
*/
/**
* thold_get_currentval() turns a raw sample into the rate a threshold is
* compared against, per the data source type.
*/
final class TholdGetCurrentvalTest extends TestCase {
const GAUGE = 1;
const COUNTER = 2;
const DERIVE = 3;
const ABSOLUTE = 4;
/**
* @return void
*/
public static function setUpBeforeClass(): void {
self::loadPluginSource('includes/functions.php');
}
/**
* @param array<string, mixed> $overrides
*
* @return array<string, mixed>
*/
private function threshold(array $overrides = []) {
return $overrides + [
'id' => 9,
'thold_id' => 9,
'local_data_id' => 4,
'name' => 'traffic_in',
'data_source_type_id' => self::COUNTER,
'rrd_step' => 300,
'rrd_maximum' => 0,
'lasttime' => 1700000000,
'oldvalue' => 100,
];
}
/**
* @param array<string, mixed> $thold
* @param float|int|string $reading
*
* @return mixed
*/
private function currentValue(array $thold, $reading) {
$reindexed = [4 => ['traffic_in' => $reading]];
$time_reindexed = [4 => 1700000300];
$item = [];
$currenttime = 0;
return thold_get_currentval($thold, $reindexed, $time_reindexed, $item, $currenttime);
}
/**
* @return void
*/
public function testGaugeReturnsTheReadingUnchanged(): void {
$thold = $this->threshold(['data_source_type_id' => self::GAUGE]);
$this->assertSame(42, $this->currentValue($thold, 42));
}
/**
* @return void
*/
public function testAbsoluteDividesTheReadingByTheStep(): void {
$thold = $this->threshold(['data_source_type_id' => self::ABSOLUTE]);
$this->assertEqualsWithDelta(2, $this->currentValue($thold, 600), 1.0e-9);
}
/**
* @return void
*/
public function testAbsoluteRejectsStaleAndOutOfOrderIntervals(): void {
foreach ([1000 + 86400, 900] as $sample_time) {
$thold = $this->threshold(['data_source_type_id' => self::ABSOLUTE, 'lasttime' => 1000]);
$reindexed = [4 => ['traffic_in' => 600]];
$time_reindexed = [4 => $sample_time];
$item = [];
$currenttime = 0;
$this->assertSame('', thold_get_currentval($thold, $reindexed, $time_reindexed, $item, $currenttime));
}
}
/**
* @return void
*/
public function testGaugeRemainsValidAcrossAGap(): void {
$thold = $this->threshold(['data_source_type_id' => self::GAUGE, 'lasttime' => 1000]);
$reindexed = [4 => ['traffic_in' => 42]];
$time_reindexed = [4 => 1000 + 7 * 86400];
$item = [];
$currenttime = 0;
$this->assertSame(42, thold_get_currentval($thold, $reindexed, $time_reindexed, $item, $currenttime));
}
/**
* @return void
*/
public function testCounterReturnsTheDeltaOverTheStep(): void {
$thold = $this->threshold(['oldvalue' => 100]);
$this->assertEqualsWithDelta(2, $this->currentValue($thold, 700), 1.0e-9);
}
/**
* A counter that legitimately read zero last cycle is not the same as
* having no previous reading. Treating it as absent reports a rate of zero
* for the first interval after a device reboot.
*
* @return void
*/
public function testCounterTreatsAPreviousReadingOfZeroAsReal(): void {
$thold = $this->threshold(['oldvalue' => 0]);
$this->assertEqualsWithDelta(2, $this->currentValue($thold, 600), 1.0e-9);
}
/**
* @return void
*/
public function testCounterWithNoPreviousReadingYieldsUnknown(): void {
$thold = $this->threshold(['lasttime' => 0, 'oldvalue' => '']);
$this->assertSame('', $this->currentValue($thold, 600));
}
/**
* A 32-bit counter that wraps has advanced by (2^32 - old) + new. Using
* 2^32-1 as the modulus loses exactly one count per wrap.
*
* @return void
*/
public function testThirtyTwoBitWrapUsesTheCorrectModulus(): void {
$thold = $this->threshold(['oldvalue' => 4294967290, 'rrd_step' => 1, 'lasttime' => 1700000299]);
$this->assertEqualsWithDelta(11, $this->currentValue($thold, 5), 1.0e-9);
}
/**
* @return void
*/
public function testSixtyFourBitWrapUsesTheCorrectModulus(): void {
$thold = $this->threshold(['oldvalue' => '18446744073709551610', 'rrd_step' => 1, 'lasttime' => 1700000299]);
$this->assertEqualsWithDelta(11, $this->currentValue($thold, 5), 1.0e-9);
}
/**
* RRD values can arrive in scientific notation. GMP accepts only integer
* strings, so these values must use the non-fatal floating-point fallback.
*
* @return void
*/
public function testSixtyFourBitWrapAcceptsScientificNotation(): void {
$thold = $this->threshold(['oldvalue' => '1.8446744073709552E+19', 'rrd_step' => 1, 'lasttime' => 1700000299]);
$this->assertEqualsWithDelta(5, $this->currentValue($thold, 5), 1.0e-9);
}
/**
* @return void
*/
public function testDeriveDividesTheDeltaByTheStep(): void {
$thold = $this->threshold(['data_source_type_id' => self::DERIVE, 'oldvalue' => 100]);
$this->assertEqualsWithDelta(2, $this->currentValue($thold, 700), 1.0e-9);
}
/**
* @return void
*/
public function testNonNumericReadingYieldsTheNoValueSentinel(): void {
$this->assertSame('', $this->currentValue($this->threshold(), 'U'));
}
/**
* @return void
*/
public function testMissingDataSourceYieldsTheNoValueSentinel(): void {
$thold = $this->threshold();
$reindexed = [];
$time_reindexed = [4 => 1700000300];
$item = [];
$currenttime = 0;
$this->assertSame('', thold_get_currentval($thold, $reindexed, $time_reindexed, $item, $currenttime));
}
/**
* @return void
*/
public function testLowerUpperCombinationRejectsUnknownInputs(): void {
$thold = ['local_data_id' => 4, 'upper_ds' => 'upper'];
$this->assertSame('', thold_calculate_lower_upper($thold, '', [4 => ['upper' => 5]]));
$this->assertSame('', thold_calculate_lower_upper($thold, 7, [4 => ['upper' => 'U']]));
$this->assertSame('', thold_calculate_lower_upper($thold, 7, [4 => []]));
$this->assertEqualsWithDelta((5 * 4294967296) + 7, thold_calculate_lower_upper($thold, 7, [4 => ['upper' => 5]]), 1.0e-9);
$this->assertEqualsWithDelta((2147483648.0 * 4294967296) + 7, thold_calculate_lower_upper($thold, 7, [4 => ['upper' => 2147483648]]), 1);
$this->assertEqualsWithDelta((4294967295.0 * 4294967296) + 7, thold_calculate_lower_upper($thold, 7, [4 => ['upper' => 4294967295]]), 1);
$this->assertSame('', thold_calculate_lower_upper($thold, 7, [4 => ['upper' => -1]]));
$this->assertSame('', thold_calculate_lower_upper($thold, 7, [4 => ['upper' => 4294967296]]));
}
/**
* @return void
*/
public function testCdefAndNestedExpressionPreserveUnknownValues(): void {
$this->assertSame('', thold_build_cdef(1, '', 4, 5));
$this->assertSame(
['sample_row' => ['id' => 9, 'tcheck' => 1, 'lastread' => '', 'lasttime' => 1700000300, 'oldvalue' => 700], 'status_row' => null],
thold_polling_sample_row($this->threshold(), ['traffic_in' => 700], '', 1700000300)
);
$nested = $this->threshold([
'lasttime' => 1000,
'rrd_heartbeat' => 600,
]);
CactiStubs::willReturn('db_fetch_row_prepared', $nested);
$outer = $this->threshold(['expression' => '|ds:traffic_in|', 'lastread' => 2]);
$reindexed = [4 => ['traffic_in' => 700]];
$time_reindexed = [4 => 1900];
$this->assertSame('', thold_calculate_expression($outer, '', $reindexed, $time_reindexed));
$this->assertSame([], CactiStubs::$log);
CactiStubs::reset();
CactiStubs::willReturn('db_fetch_row_prepared', $nested);
$time_reindexed[4] = 1300;
$this->assertEqualsWithDelta(2, thold_calculate_expression($outer, '', $reindexed, $time_reindexed), 1.0e-9);
CactiStubs::reset();
CactiStubs::willReturn('db_fetch_row_prepared', []);
CactiStubs::willReturn('db_fetch_row_prepared', ['data_source_type_id' => self::COUNTER]);
CactiStubs::willReturn('db_fetch_row_prepared', ['rrd_step' => 300]);
CactiStubs::willReturn('rrdtool_execute', '1700000000');
CactiStubs::willReturn('rrdtool_function_fetch', [
'data_source_names' => ['traffic_in'],
'values' => [['1700000000' => 2.0]],
]);
$this->assertEqualsWithDelta(2.0, thold_calculate_expression($outer, '', $reindexed, $time_reindexed), 1.0e-9);
$this->assertSame([], CactiStubs::$log);
CactiStubs::reset();
CactiStubs::$configOptions['dsstats_enable'] = 'on';
CactiStubs::willReturn('db_fetch_row_prepared', []);
CactiStubs::willReturn('db_fetch_row_prepared', ['data_source_type_id' => self::COUNTER]);
CactiStubs::willReturn('db_fetch_cell_prepared', 3.5);
$this->assertEqualsWithDelta(3.5, thold_calculate_expression($outer, '', $reindexed, $time_reindexed), 1.0e-9);
$this->assertSame([], CactiStubs::callsTo('rrdtool_function_fetch'));
CactiStubs::reset();
CactiStubs::willReturn('db_fetch_row_prepared', []);
CactiStubs::willReturn('db_fetch_row_prepared', ['data_source_type_id' => self::GAUGE]);
$this->assertSame('700', thold_calculate_expression($outer, '', $reindexed, $time_reindexed));
$this->assertSame([], CactiStubs::callsTo('rrdtool_function_fetch'));
foreach ([
[],
['data_source_names' => ['traffic_out'], 'values' => [['1700000000' => 2.0]]],
] as $missing_fetch) {
CactiStubs::reset();
CactiStubs::willReturn('db_fetch_row_prepared', []);
CactiStubs::willReturn('db_fetch_row_prepared', ['data_source_type_id' => self::COUNTER]);
CactiStubs::willReturn('db_fetch_row_prepared', ['rrd_step' => 300]);
CactiStubs::willReturn('rrdtool_execute', '1700000000');
CactiStubs::willReturn('rrdtool_function_fetch', $missing_fetch);
$this->assertSame('', thold_calculate_expression($outer, '', $reindexed, $time_reindexed));
}
CactiStubs::reset();
CactiStubs::willReturn('db_fetch_row_prepared', []);
CactiStubs::willReturn('db_fetch_row_prepared', []);
$this->assertSame('', thold_calculate_expression($outer, '', $reindexed, $time_reindexed));
CactiStubs::reset();
CactiStubs::willReturn('db_fetch_row_prepared', []);
$reindexed = [];
$this->assertSame('', thold_calculate_expression($outer, '', $reindexed, $time_reindexed));
$this->assertStringContainsString('expression source traffic_in is unavailable', CactiStubs::$log[0]);
$log_call = CactiStubs::callsTo('cacti_log')[0];
$this->assertSame('THOLD', $log_call['params'][2]);
$this->assertSame(POLLER_VERBOSITY_MEDIUM, $log_call['params'][3]);
}
/**
* @return void
*/
public function testMissedSampleCarriesTheValueAndTimestampTogether(): void {
$thold = $this->threshold(['lasttime' => 1000, 'oldvalue' => 100]);
$missed = thold_sample_persistence($thold, [], 1300);
$this->assertSame(['lasttime' => 1000, 'oldvalue' => 100], $missed);
$this->assertSame($missed, thold_sample_persistence($thold, ['traffic_in' => 'U'], 1300));
$this->assertSame($missed, thold_sample_persistence($thold, ['traffic_in' => 'nan'], 1300));
$this->assertSame($missed, thold_sample_persistence($thold, ['traffic_in' => ''], 1300));
$this->assertSame(
['lasttime' => 1600, 'oldvalue' => 700],
thold_sample_persistence($thold, ['traffic_in' => 700], 1600)
);
$this->assertSame(
['lasttime' => 1600, 'oldvalue' => '700'],
thold_sample_persistence($thold, ['traffic_in' => '700'], 1600)
);
$thold['lasttime'] = $missed['lasttime'];
$thold['oldvalue'] = $missed['oldvalue'];
$reindexed = [4 => ['traffic_in' => 700]];
$time_reindexed = [4 => 1600];
$item = [];
$currenttime = 0;
$this->assertEqualsWithDelta(
1,
thold_get_currentval($thold, $reindexed, $time_reindexed, $item, $currenttime),
1.0e-9
);
}
/**
* A numeric raw sample that arrives after a gap beyond the effective
* heartbeat still re-anchors the pair to the current sample and time:
* thold_get_currentval() already rejected this cycle's rate using the
* pair as it stood before this call, so freezing lasttime/oldvalue here
* would buy no extra protection for the current read while permanently
* disqualifying every later poll, whose elapsed time is measured against
* that same frozen lasttime and only grows. A gauge has no rate baseline
* to protect, and the first-ever sample and a backward-clock re-anchor
* always advance the pair regardless of the gap.
*
* @return void
*/
public function testStaleGapReanchorsARateBearingPairAndAllOtherCasesStillAdvance(): void {
$thold = $this->threshold(['lasttime' => 1000, 'oldvalue' => 100, 'rrd_step' => 300]);
// 1000 -> 1601: a 601s gap exceeds the 600s effective heartbeat (2 * 300s),
// but the pair still re-anchors so the next poll can recover.
$this->assertSame(
['lasttime' => 1601, 'oldvalue' => 700],
thold_sample_persistence($thold, ['traffic_in' => 700], 1601)
);
// Right at the heartbeat boundary, the pair still advances.
$this->assertSame(
['lasttime' => 1600, 'oldvalue' => 700],
thold_sample_persistence($thold, ['traffic_in' => 700], 1600)
);
// A gauge has no baseline to protect, so the same gap still advances it.
$gauge = $this->threshold(['data_source_type_id' => self::GAUGE, 'lasttime' => 1000, 'oldvalue' => 100, 'rrd_step' => 300]);
$this->assertSame(
['lasttime' => 1601, 'oldvalue' => 700],
thold_sample_persistence($gauge, ['traffic_in' => 700], 1601)
);
// No prior sample at all: the gap check does not apply.
$firstSample = $this->threshold(['lasttime' => 0, 'oldvalue' => null, 'rrd_step' => 300]);
$this->assertSame(
['lasttime' => 1601, 'oldvalue' => 700],
thold_sample_persistence($firstSample, ['traffic_in' => 700], 1601)
);
// A backward clock always re-anchors, even across a heartbeat-exceeding gap.
$backward = $this->threshold(['lasttime' => 5000, 'oldvalue' => 100, 'rrd_step' => 300]);
$this->assertSame(
['lasttime' => 1601, 'oldvalue' => 700],
thold_sample_persistence($backward, ['traffic_in' => 700], 1601)
);
}
/**
* issue #815: a stale gap must not lock a rate-bearing threshold out of
* ever recovering. The first (gap) poll re-anchors the pair; the second
* poll, arriving at a normal interval from that new anchor, must produce
* a real numeric rate rather than being rejected as stale again.
*
* @return void
*/
public function testASecondPollAtANormalIntervalRecoversAfterAStaleGapReanchors(): void {
$thold = $this->threshold(['lasttime' => 1000, 'oldvalue' => 100, 'rrd_step' => 300]);
// First (gap) poll: re-anchors instead of freezing.
$afterGap = thold_sample_persistence($thold, ['traffic_in' => 700], 1601);
$this->assertSame(['lasttime' => 1601, 'oldvalue' => 700], $afterGap);
// Second poll, a normal 300s interval after the re-anchored pair.
$recovered = $this->threshold(['lasttime' => $afterGap['lasttime'], 'oldvalue' => $afterGap['oldvalue'], 'rrd_step' => 300]);
$reindexed = [4 => ['traffic_in' => 900]];
$timeReindexed = [4 => 1901];
$item = [];
$currenttime = 0;
// A COUNTER's delta is a per-second rate: (900 - 700) / (1901 - 1601).
$this->assertEqualsWithDelta(
200 / 300,
thold_get_currentval($recovered, $reindexed, $timeReindexed, $item, $currenttime),
1.0e-9
);
}
/**
* The re-anchor warning fires only on the poll that first crosses the
* heartbeat, while the prior cycle's persisted rate (lastread) is still
* numeric. A source that stays stale poll after poll has already had its
* rate replaced with an unavailable state, so it must not keep logging,
* or a persistently flaky device floods medium-verbosity logs.
*
* @return void
*/
public function testStaleGapReanchorLogsOnlyOnceOnTheTransitionIntoUnavailable(): void {
$thold = $this->threshold(['lasttime' => 1000, 'oldvalue' => 100, 'rrd_step' => 300, 'lastread' => 50]);
thold_sample_persistence($thold, ['traffic_in' => 700], 1601);
$this->assertCount(1, CactiStubs::$log);
$this->assertStringContainsString('sample gap exceeded', CactiStubs::$log[0]);
CactiStubs::reset();
// Same kind of gap, but the previous cycle already persisted the
// rate as unavailable: no further warning.
$stillStale = $this->threshold(['lasttime' => 1601, 'oldvalue' => 700, 'rrd_step' => 300, 'lastread' => '']);
thold_sample_persistence($stillStale, ['traffic_in' => 900], 2500);
$this->assertSame([], CactiStubs::$log);
}
/**
* The effective heartbeat thold_sample_interval_eligible() compares
* against honors an explicitly configured rrd_heartbeat, and falls back
* to the poller interval alone when rrd_step is not a usable positive
* number - mirroring thold_get_currentval()'s own fallbacks. The pair
* always advances; the heartbeat comparison now only affects whether a
* re-anchor is logged.
*
* @return void
*/
public function testEligibilityHonorsAConfiguredHeartbeatAndFallsBackForAnInvalidRrdStep(): void {
$withHeartbeat = $this->threshold(['lasttime' => 1000, 'oldvalue' => 100, 'rrd_step' => 300, 'rrd_heartbeat' => 1800]);
$this->assertSame(
['lasttime' => 2800, 'oldvalue' => 700],
thold_sample_persistence($withHeartbeat, ['traffic_in' => 700], 2800)
);
$this->assertSame(
['lasttime' => 2801, 'oldvalue' => 700],
thold_sample_persistence($withHeartbeat, ['traffic_in' => 700], 2801)
);
$invalidStep = $this->threshold(['lasttime' => 1000, 'oldvalue' => 100, 'rrd_step' => 0]);
$this->assertSame(
['lasttime' => 1600, 'oldvalue' => 700],
thold_sample_persistence($invalidStep, ['traffic_in' => 700], 1600)
);
$this->assertSame(
['lasttime' => 1601, 'oldvalue' => 700],
thold_sample_persistence($invalidStep, ['traffic_in' => 700], 1601)
);
}
/**
* @return array<string, array{0: int|string|null, 1: int, 2: float}>
*/
public static function emptyMaximumProvider() {
return [
'integer zero' => [0, 1009000000100, 1009000000100 / 600],
'empty string' => ['', 1009000000100, 1009000000100 / 600],
'null maximum' => [null, 1009000000100, 1009000000100 / 600],
'unknown maximum' => ['U', 1009000000100, 1009000000100 / 600],
'unresolved if speed' => ['|query_ifSpeed|', 1009000000100, 1009000000100 / 600],
'explicit maximum' => [20000000, 1015000000000, 1015000000000 / 600],
];
}
/**
* @dataProvider emptyMaximumProvider
*
* @param int|string|null $maximum
* @param int $reading
* @param float $expected
*
* @return void
*/
public function testMultiIntervalDeltaScalesTheResetGuard($maximum, $reading, $expected): void {
if ($maximum === '|query_ifSpeed|') {
CactiStubs::willReturn('db_fetch_row_prepared', ['host_id' => 1, 'snmp_query_id' => 2, 'snmp_index' => 'eth0']);
CactiStubs::willReturn('db_fetch_cell_prepared', '');
}
$thold = $this->threshold(['lasttime' => 1000, 'oldvalue' => 1000000000000, 'rrd_maximum' => $maximum]);
$reindexed = [4 => ['traffic_in' => $reading]];
$time_reindexed = [4 => 1600];
$item = [];
$currenttime = 0;
$this->assertEqualsWithDelta($expected, thold_get_currentval($thold, $reindexed, $time_reindexed, $item, $currenttime), 1.0e-9);
}
/**
* @return void
*/
public function testMultiIntervalWrapUsesTheWholeElapsedTime(): void {
$thold = $this->threshold(['lasttime' => 1000, 'oldvalue' => 4294967290, 'rrd_maximum' => 0]);
$reindexed = [4 => ['traffic_in' => 5]];
$time_reindexed = [4 => 1600];
$item = [];
$currenttime = 0;
$this->assertEqualsWithDelta(
11 / 600,
thold_get_currentval($thold, $reindexed, $time_reindexed, $item, $currenttime),
1.0e-9
);
}
/**
* @return void
*/
public function testWrapResetGuardUsesTheEffectiveSampleInterval(): void {
CactiStubs::$configOptions['poller_interval'] = 300;
$thold = $this->threshold([
'lasttime' => 1700000000,
'oldvalue' => 1000,
'rrd_step' => 60,
'rrd_maximum' => 0,
]);
$this->assertEqualsWithDelta(999 / 300, $this->currentValue($thold, 999), 1.0e-9);
}
/**
* @return void
*/
public function testStaleCounterAndDeriveSamplesAreDiscarded(): void {
foreach ([self::COUNTER, self::DERIVE] as $type) {
$thold = $this->threshold(['data_source_type_id' => $type, 'lasttime' => 1000, 'oldvalue' => 100]);
$reindexed = [4 => ['traffic_in' => 700]];
$time_reindexed = [4 => 1000 + 7 * 86400];
$item = [];
$currenttime = 0;
$this->assertSame('', thold_get_currentval($thold, $reindexed, $time_reindexed, $item, $currenttime));
}
}
/**
* @return array<string, array{0: mixed}>
*/
public static function invalidRrdStepProvider() {
return [
'zero' => [0],
'null' => [null],
'non-numeric' => ['invalid'],
];
}
/**
* @dataProvider invalidRrdStepProvider
*
* @param mixed $rrd_step
*
* @return void
*/
public function testInvalidRrdStepFallsBackToThePollerInterval($rrd_step): void {
CactiStubs::$configOptions['poller_interval'] = 300;
$thold = $this->threshold([
'data_source_type_id' => self::ABSOLUTE,
'lasttime' => 0,
'rrd_step' => $rrd_step,
]);
$this->assertEqualsWithDelta(2, $this->currentValue($thold, 600), 1.0e-9);
}
/**
* @return void
*/
public function testEvaluationCadenceMayExceedTheRrdStep(): void {
CactiStubs::$configOptions['poller_interval'] = 300;
$thold = $this->threshold(['rrd_step' => 60]);
$this->assertEqualsWithDelta(2, $this->currentValue($thold, 700), 1.0e-9);
}
/**
* @return void
*/
public function testRrdHeartbeatControlsGapAcceptanceWithASafeFloor(): void {
$accepted = $this->threshold(['lasttime' => 1000, 'rrd_heartbeat' => 1800]);
$reindexed = [4 => ['traffic_in' => 700]];
$time_reindexed = [4 => 1900];
$item = [];
$currenttime = 0;
$this->assertEqualsWithDelta(
600 / 900,
thold_get_currentval($accepted, $reindexed, $time_reindexed, $item, $currenttime),
1.0e-9
);
$floored = $accepted;
$floored['rrd_heartbeat'] = 120;
$time_reindexed[4] = 1300;
$this->assertEqualsWithDelta(
2,
thold_get_currentval($floored, $reindexed, $time_reindexed, $item, $currenttime),
1.0e-9
);
$time_reindexed[4] = 1700;
$this->assertSame('', thold_get_currentval($floored, $reindexed, $time_reindexed, $item, $currenttime));
}
/**
* @return void
*/
public function testNonNumericSampleTimeUsesTheRrdStep(): void {
$thold = $this->threshold();
$reindexed = [4 => ['traffic_in' => 700]];
$time_reindexed = [4 => 'U'];
$item = [];
$currenttime = 0;
$this->assertEqualsWithDelta(2, thold_get_currentval($thold, $reindexed, $time_reindexed, $item, $currenttime), 1.0e-9);
}
/**
* @return void
*/
public function testFirstAbsoluteSampleUsesTheValidatedPollerInterval(): void {
CactiStubs::$configOptions['poller_interval'] = 300;
$thold = $this->threshold([
'data_source_type_id' => self::ABSOLUTE,
'lasttime' => 0,
'rrd_step' => 0,
]);
$this->assertEqualsWithDelta(2, $this->currentValue($thold, 600), 1.0e-9);
}
/**
* @return void
*/
public function testOutOfOrderCounterAndDeriveSamplesAreUnknown(): void {
foreach ([self::COUNTER, self::DERIVE] as $type) {
$thold = $this->threshold(['data_source_type_id' => $type, 'lasttime' => 1700000400]);
$reindexed = [4 => ['traffic_in' => 700]];
$time_reindexed = [4 => 1700000300];
$item = [];
$currenttime = 0;
$this->assertSame('', thold_get_currentval($thold, $reindexed, $time_reindexed, $item, $currenttime));
}
$this->assertSame(
['lasttime' => 1700000300, 'oldvalue' => 700],
thold_sample_persistence($thold, ['traffic_in' => 700], 1700000300)
);
$this->assertSame(
['lasttime' => 1700000400, 'oldvalue' => 100],
thold_sample_persistence($thold, ['traffic_in' => 700], 1700000400)
);
CactiStubs::reset();
$this->assertSame([
'sample_row' => ['id' => 9, 'tcheck' => 1, 'lastread' => '', 'lasttime' => 1700000300, 'oldvalue' => 700],
'status_row' => null,
], thold_polling_sample_row($thold, ['traffic_in' => 700], '', 1700000300));
$this->assertCount(1, CactiStubs::$log);
$this->assertStringContainsString('clock moved backwards', CactiStubs::$log[0]);
CactiStubs::reset();
$this->assertTrue(thold_daemon_persist_sample($thold, ['traffic_in' => 700], '', 1700000300));
$this->assertCount(1, CactiStubs::$log);
$this->assertStringContainsString('clock moved backwards', CactiStubs::$log[0]);
$call = end(CactiStubs::$calls);
$this->assertSame([1, '', 1700000300, 700, 9], $call['params']);
$reanchored = $this->threshold(['lasttime' => 1700000300, 'oldvalue' => 700]);
$reindexed = [4 => ['traffic_in' => 1000]];
$time_reindexed = [4 => 1700000600];
$item = [];
$currenttime = 0;
$this->assertEqualsWithDelta(
1,
thold_get_currentval($reanchored, $reindexed, $time_reindexed, $item, $currenttime),
1.0e-9
);
}
/**
* @return void
*/
public function testDeriveWithAnInvalidPriorValueIsUnknown(): void {
$thold = $this->threshold([
'data_source_type_id' => self::DERIVE,
'oldvalue' => 'U',
]);
$this->assertSame('', $this->currentValue($thold, 700));
}
/**
* @return void
*/
public function testDaemonPersistsThePairWithBoundParameters(): void {
$thold = $this->threshold(['lasttime' => 1000, 'oldvalue' => 100]);
$this->assertTrue(thold_daemon_persist_sample($thold, [], '', 1300));
$call = end(CactiStubs::$calls);
$this->assertStringContainsString('lasttime = FROM_UNIXTIME(?)', $call['sql']);
$this->assertSame([1, '', 1000, 100, 9], $call['params']);
CactiStubs::reset();
$this->assertTrue(thold_daemon_persist_sample($thold, ['traffic_in' => 700], 2, 1600));
$call = end(CactiStubs::$calls);
$this->assertSame([1, 2, 1600, 700, 9], $call['params']);
}
/**
* @return void
*/
public function testDaemonPropagatesPersistenceFailure(): void {
CactiStubs::willReturn('db_execute_prepared', false);
$this->assertFalse(thold_daemon_persist_sample(
$this->threshold(['lasttime' => 1000]),
[],
'',
1300
));
CactiStubs::reset();
CactiStubs::willReturn('db_execute_prepared', false);
$this->assertFalse(thold_daemon_persist_sample(
$this->threshold(['lasttime' => 0]),
[],
'',
1300
));
}
/**
* @return void
*/
public function testNeverSampledThresholdLeavesTheTimestampPairUntouched(): void {
$thold = $this->threshold(['lasttime' => 0, 'oldvalue' => null]);
$this->assertSame(
['lasttime' => 0, 'oldvalue' => null],
thold_sample_persistence($thold, ['traffic_in' => 'U'], 1300)
);
$this->assertTrue(thold_daemon_persist_sample($thold, ['traffic_in' => 'U'], '', 1300));
$call = end(CactiStubs::$calls);
$this->assertStringNotContainsString('FROM_UNIXTIME', $call['sql']);
$this->assertStringNotContainsString('oldvalue', $call['sql']);
$this->assertSame([1, '', 9], $call['params']);
CactiStubs::reset();
$this->assertSame(
['sample_row' => null, 'status_row' => ['id' => 9, 'tcheck' => 1, 'lastread' => '']],
thold_polling_sample_row($thold, ['traffic_in' => 'U'], '', 1300)
);
$this->assertSame([], CactiStubs::$calls);
}
/**
* @return void
*/
public function testPollerBuildsTheSamePersistedPair(): void {
$thold = $this->threshold(['lasttime' => 1000, 'oldvalue' => 100]);
$this->assertSame([
'sample_row' => ['id' => 9, 'tcheck' => 1, 'lastread' => 2, 'lasttime' => 1000, 'oldvalue' => 100],
'status_row' => null,
], thold_polling_sample_row($thold, [], 2, 1300));
$this->assertSame([
'sample_row' => ['id' => 9, 'tcheck' => 1, 'lastread' => 2, 'lasttime' => 1600, 'oldvalue' => 700],
'status_row' => null,
], thold_polling_sample_row($thold, ['traffic_in' => 700], 2, 1600));
$this->assertSame([
'sample_row' => ['id' => 9, 'tcheck' => 1, 'lastread' => 2, 'lasttime' => 1000, 'oldvalue' => null],
'status_row' => null,
], thold_polling_sample_row($this->threshold(['lasttime' => 1000, 'oldvalue' => null]), [], 2, 1300));
}
/**
* @return void
*/
public function testMissingPersistenceKeysFailClosed(): void {
$this->assertSame(
['lasttime' => 0, 'oldvalue' => null],
thold_sample_persistence([], ['traffic_in' => 700], 1600)
);
$this->assertFalse(thold_daemon_persist_sample([], ['traffic_in' => 700], 2, 1600));
$this->assertSame(
['sample_row' => null, 'status_row' => null],
thold_polling_sample_row([], ['traffic_in' => 700], 2, 1600)
);
}
/**
* @return void
*/
public function testUnavailableSampleLogsOnlyOnTheStateTransition(): void {
$thold = $this->threshold([
'lastread' => 12,
'name_cache' => 'Traffic in',
]);
thold_polling_sample_row($thold, [], '', 1700000300);
$this->assertCount(1, CactiStubs::$log);
$log_call = CactiStubs::callsTo('cacti_log')[0];
$this->assertSame('THOLD', $log_call['params'][2]);
$this->assertSame(POLLER_VERBOSITY_MEDIUM, $log_call['params'][3]);
$thold['lastread'] = '';
thold_polling_sample_row($thold, [], '', 1700000600);
$this->assertCount(1, CactiStubs::$log);
}
/**
* @return void
*/
public function testPollerCleanupRunsForEitherBatchType(): void {
thold_polling_cleanup(false);
$this->assertSame([], CactiStubs::$calls);
CactiStubs::willReturn('db_affected_rows', 1);
thold_polling_cleanup(true);
$this->assertSame('db_execute_prepared', CactiStubs::$calls[0]['fn']);
$this->assertStringContainsString('local_data_id = 0', CactiStubs::$calls[0]['sql']);
$this->assertArrayHasKey('time_last_change_thold', CactiStubs::$configOptions);
}
}