Files
cacti/tests/Unit/Core/Rrd/RrdCfFallbackTest.php
Sean Mancini 96033267cb refactor(tests): reorganize unit tests into domain category folders (#7733)
* refactor(tests): reorganize unit tests into domain category folders

- Consolidate per-advisory GHSA regression tests into Security/ subfolders by vuln class
- Group all 186 test files into domain folders: DataCollection/, Ui/, Core/,
  Database/, Installer/, Scripts/, Security/, Plugin/
- Strip GitHub issue/GHSA numbers from filenames; keep IDs in test descriptions
- Fix all __DIR__ relative paths to match new folder nesting depth
- Update CI workflow path triggers and phpunit XML configs for new file locations
- Update .github/copilot-instructions.md with unit test organization guidelines
- Add tests/vendor/ to .gitignore

* add gaurds

* fix up guard for unit tests

* fix redeclaration of cacti_log in tests

* fix updates to unit tests

* fix test patjhs

* fix: update test __DIR__ paths for reorganized test structure

* fix: conditionally define read_user_setting to prevent redeclaration in tests

* fix: conditionally define functions to prevent redeclaration in tests

- Wrap clean_up_lines, raise_message, cacti_debug_backtrace, and get_debug_prefix
  with function_exists() checks to prevent conflicts when test stubs are loaded
  before lib/functions.php in the same PHP process

* fix: add unit test bootstrap to prevent function redeclaration errors

- Create tests/bootstrap-unit.php to load core libraries before tests
- Ensures lib/functions.php loads before test stubs define stub functions
- Test stubs use function_exists() checks, so they detect pre-loaded functions
- Update phpunit.xml to use bootstrap-unit.php for Unit testsuite
- Fixes 'Cannot redeclare read_user_setting()' errors when tests run in same process

* fix: load test stubs before global.php in unit test bootstrap

- Reorder bootstrap to load CactiStubs.php and UnitStubs.php first
- Ensures stub functions like __() are available when global.php loads
- global.php calls get_cacti_version_text() which needs __() for translations
- Fixes 'Call to undefined function __()' error during bootstrap

* fix: remove function conflicts from test stubs

- Remove stub definitions for functions already in lib/functions.php:
  cacti_sizeof, cacti_log, cacti_strtolower, read_config_option,
  read_user_setting, cacti_count
- Keep translation function stubs (__(), __esc()) needed by global.php
- Update bootstrap to load lib/functions.php after stubs
- This prevents function redeclaration errors while maintaining test isolation

* fix: prevent constant and variable redeclaration in bootstrap

- Remove explicit PHP_TESTING and CACTI_TEST_BOOTSTRAP defines from bootstrap
  (CactiStubs.php now handles this with guards)
- Add guards to CactiStubs.php to prevent redefinition of PHP_TESTING
- Only set CACTI_TEST_BOOTSTRAP env vars if not already set
- Fixes PHP warnings about 'Constant X already defined'

* fix: remove redundant lib/functions.php load from bootstrap

include/global.php already loads lib/functions.php at lines 287-288.
The bootstrap was trying to load it again after global.php, but global.php
needs format_cacti_version() to be available on lines 293-294 to define
CACTI_VERSION and CACTI_VERSION_FULL constants.

Solution: Let global.php handle loading lib/functions.php itself, and don't
load it separately in the bootstrap.

* fix: remove more conflicting function stubs from test helpers

- Remove stubs for functions also defined in lib/functions.php:
  raise_message, clean_up_lines, cacti_debug_backtrace, get_debug_prefix
- Remove POLLER_ID constant stub (defined unconditionally by global.php)
- Keep only translation functions and test-specific helpers
- This prevents function/constant redeclaration errors

* fix: minimize test stubs to only translation functions

Remove ALL helper functions and constants from UnitStubs.php that are:
- Defined in lib/functions.php (raise_message, clean_up_lines, etc.)
- Defined in lib/html_utility.php (srv, set_request_var)
- Defined in global_constants.php (POLLER_VERBOSITY_*, MESSAGE_LEVEL_*)
- Defined in global.php (POLLER_ID)

Keep ONLY translation functions (__(), __esc()) that are:
- Called by global.php during initialization
- Not defined elsewhere
- Required for proper bootstrap sequence

This eliminates all constant and function redeclaration errors.

* fix: remove __esc stub - already defined in global_languages.php

Keep only __() translation function stub, which is needed by global.php
and not defined elsewhere in Cacti core.

* fix: handle no-arg case in __() translation stub

The __() function may be called with just a string and no format arguments.
Previously, vsprintf() would fail with 'arguments array must contain 2 items'.
Now, if no args are provided, return the text as-is.
2026-08-16 19:23:31 -07:00

212 lines
7.6 KiB
PHP

<?php
declare(strict_types=1);
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
/*
* Regression test for CF fallback overwrite bug in rrdtool_function_graph().
*
* The bug was at lib/rrd.php:2060 where $cf_id was unconditionally
* overwritten with $graph_item['cf_reference'] after the fallback
* chain at lines 2041-2056 had selected an available CF.
*
* @group regression
*/
require_once dirname(__DIR__, 3) . '/Helpers/CactiStubs.php';
/**
* Simulate the CF fallback selection logic (FIXED version).
*
* When the preferred CF has no DEF, falls back to AVERAGE > MAX > MIN > LAST.
*
* @param array $cf_ds_cache Available CF/DS combinations (keyed as "rrd_id:cf_id")
* @param int $data_template_rrd_id The data template RRD ID
* @param int $preferred_cf The preferred consolidation function ID
* @param int $cf_reference The graph item's cf_reference (was used to overwrite)
*
* @return int The selected CF ID
*/
function cf_fallback_fixed(array $cf_ds_cache, int $data_template_rrd_id, int $preferred_cf, int $cf_reference): int {
$cf_ds_key = "$data_template_rrd_id:$preferred_cf";
if (isset($cf_ds_cache[$cf_ds_key])) {
return $preferred_cf;
}
// Fallback: AVERAGE(1) > MAX(3) > MIN(2) > LAST(4)
if (isset($cf_ds_cache["$data_template_rrd_id:1"])) return 1;
if (isset($cf_ds_cache["$data_template_rrd_id:3"])) return 3;
if (isset($cf_ds_cache["$data_template_rrd_id:2"])) return 2;
if (isset($cf_ds_cache["$data_template_rrd_id:4"])) return 4;
return 1; // default AVERAGE
}
/**
* Simulate the BUGGY version that overwrites fallback with cf_reference.
*
* @return int Always returns cf_reference (bug)
*/
function cf_fallback_buggy(array $cf_ds_cache, int $data_template_rrd_id, int $preferred_cf, int $cf_reference): int {
$cf_ds_key = "$data_template_rrd_id:$preferred_cf";
if (isset($cf_ds_cache[$cf_ds_key])) {
$cf_id = $preferred_cf;
} else {
if (isset($cf_ds_cache["$data_template_rrd_id:1"])) { $cf_id = 1; }
elseif (isset($cf_ds_cache["$data_template_rrd_id:3"])) { $cf_id = 3; }
elseif (isset($cf_ds_cache["$data_template_rrd_id:2"])) { $cf_id = 2; }
elseif (isset($cf_ds_cache["$data_template_rrd_id:4"])) { $cf_id = 4; }
else { $cf_id = 1; }
}
// BUG: overwrite with cf_reference (was line 2060)
$cf_id = $cf_reference;
return $cf_id;
}
// ===========================================================================
// Fixed behavior tests
// ===========================================================================
describe('CF fallback selection (fixed)', function () {
test('preferred CF available: uses it directly', function () {
$cache = ['10:3' => true, '10:1' => true];
expect(cf_fallback_fixed($cache, 10, 3, 3))->toBe(3);
});
test('preferred CF missing: falls back to AVERAGE', function () {
$cache = ['10:1' => true]; // only AVERAGE available
expect(cf_fallback_fixed($cache, 10, 3, 3))->toBe(1);
});
test('preferred CF missing, only MAX available: falls back to MAX', function () {
$cache = ['10:3' => true];
expect(cf_fallback_fixed($cache, 10, 1, 1))->toBe(3);
});
test('preferred CF missing, only MIN available: falls back to MIN', function () {
$cache = ['10:2' => true];
expect(cf_fallback_fixed($cache, 10, 3, 3))->toBe(2);
});
test('preferred CF missing, only LAST available: falls back to LAST', function () {
$cache = ['10:4' => true];
expect(cf_fallback_fixed($cache, 10, 1, 1))->toBe(4);
});
test('no CFs available: defaults to AVERAGE (1)', function () {
expect(cf_fallback_fixed([], 10, 3, 3))->toBe(1);
});
test('fallback priority: AVERAGE > MAX > MIN > LAST', function () {
// All available: AVERAGE wins
$all = ['10:1' => true, '10:2' => true, '10:3' => true, '10:4' => true];
expect(cf_fallback_fixed($all, 10, 99, 99))->toBe(1);
// No AVERAGE: MAX wins
$no_avg = ['10:2' => true, '10:3' => true, '10:4' => true];
expect(cf_fallback_fixed($no_avg, 10, 99, 99))->toBe(3);
// No AVERAGE or MAX: MIN wins
$no_avg_max = ['10:2' => true, '10:4' => true];
expect(cf_fallback_fixed($no_avg_max, 10, 99, 99))->toBe(2);
// Only LAST: LAST wins
$only_last = ['10:4' => true];
expect(cf_fallback_fixed($only_last, 10, 99, 99))->toBe(4);
});
});
// ===========================================================================
// Bug demonstration
// ===========================================================================
describe('Bug demonstration: buggy vs fixed', function () {
test('buggy version ignores fallback, always returns cf_reference', function () {
$cache = ['10:1' => true]; // AVERAGE available
// Preferred CF 3 (MAX) is not available, fallback should give 1 (AVERAGE)
$buggy = cf_fallback_buggy($cache, 10, 3, 3);
expect($buggy)->toBe(3); // BUG: returns cf_reference instead of fallback
});
test('fixed version correctly falls back', function () {
$cache = ['10:1' => true];
$fixed = cf_fallback_fixed($cache, 10, 3, 3);
expect($fixed)->toBe(1); // Correctly returns AVERAGE
});
test('buggy and fixed produce different results when fallback needed', function () {
$cache = ['10:1' => true];
$buggy = cf_fallback_buggy($cache, 10, 3, 3);
$fixed = cf_fallback_fixed($cache, 10, 3, 3);
expect($buggy)->not->toBe($fixed);
});
test('both agree when preferred CF is available', function () {
$cache = ['10:3' => true];
$buggy = cf_fallback_buggy($cache, 10, 3, 3);
$fixed = cf_fallback_fixed($cache, 10, 3, 3);
expect($buggy)->toBe($fixed)->toBe(3);
});
});
// ===========================================================================
// Mutation killers
// ===========================================================================
describe('CF fallback mutation killers', function () {
test('fallback returns different CF than preferred when preferred missing', function () {
$cache = ['10:1' => true];
$r = cf_fallback_fixed($cache, 10, 3, 3);
expect($r)->toBe(1);
expect($r)->not->toBe(3);
});
test('AVERAGE(1) and MAX(3) are distinct fallback results', function () {
$avg_only = ['10:1' => true];
$max_only = ['10:3' => true];
expect(cf_fallback_fixed($avg_only, 10, 99, 99))->toBe(1);
expect(cf_fallback_fixed($max_only, 10, 99, 99))->toBe(3);
});
test('deterministic for same inputs', function () {
$cache = ['10:1' => true, '10:3' => true];
$first = cf_fallback_fixed($cache, 10, 99, 99);
$second = cf_fallback_fixed($cache, 10, 99, 99);
expect($second)->toBe($first);
});
test('different rrd_ids are independent', function () {
$cache = ['10:1' => true, '20:3' => true];
expect(cf_fallback_fixed($cache, 10, 99, 99))->toBe(1);
expect(cf_fallback_fixed($cache, 20, 99, 99))->toBe(3);
});
});
// ===========================================================================
// Source verification: bug is fixed
// ===========================================================================
describe('Source verification: CF overwrite removed', function () {
test('no unconditional cf_reference overwrite after fallback chain', function () {
$c = file_get_contents(__DIR__ . '/../../../../lib/rrd.php');
$p = strpos($c, 'first we need to check if there is a DEF');
$s = substr($c, $p, 1000);
// The fallback chain should exist
expect($s)->toContain('CF: AVERAGE');
expect($s)->toContain('CF: MAX');
// But the overwrite line should NOT exist
expect($s)->not->toContain('$cf_id = $graph_item[\'cf_reference\']');
});
});