fix(automation): bind template IDs and enforce 100% coverage (#7612)

* Harden automation template ID filters

* Match automation test size semantics

* Test API automation tools at 100% coverage

* Make API coverage fixture load-order safe

* fix(tests): isolate API coverage stubs from unit suite

* ci(coverage): isolate API automation coverage workflow

* perf(automation): eliminate tree display N+1 queries

---------

Co-authored-by: TheWitness <thewitness@cacti.net>
This commit is contained in:
Thomas VincentandTheWitness authored and GitHub committed 2026-08-10 12:33:26 -04:00
1 parent eaa1f022a9
commit ee3ea262d8
6 files changed
+520 -78

No files matched your search

@@ -0,0 +1,74 @@
name: API Automation Tools Coverage
on:
push:
branches: [develop]
paths:
- '.github/workflows/api-automation-tools-coverage.yml'
- 'composer.json'
- 'composer.lock'
- 'lib/api_automation_tools.php'
- 'phpunit-api-automation-tools.xml'
- 'tests/Coverage/ApiAutomationToolsCoverageTest.php'
- 'tests/Unit/AutomationPreparedInClauseTest.php'
- 'tests/fixtures/automation_prepared_in_clause_probe.php'
pull_request:
branches: [develop]
paths:
- '.github/workflows/api-automation-tools-coverage.yml'
- 'composer.json'
- 'composer.lock'
- 'lib/api_automation_tools.php'
- 'phpunit-api-automation-tools.xml'
- 'tests/Coverage/ApiAutomationToolsCoverageTest.php'
- 'tests/Unit/AutomationPreparedInClauseTest.php'
- 'tests/fixtures/automation_prepared_in_clause_probe.php'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: api-automation-tools-coverage-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
COMPOSER_ALLOW_SUPERUSER: 1
jobs:
coverage:
name: API automation tools 100% coverage
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Setup PHP 8.4 with Xdebug
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: '8.4'
coverage: xdebug
extensions: intl, mysql, gd, ldap, gmp, xml, curl, json, mbstring
- name: Cache Composer packages
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: include/vendor
key: ${{ runner.os }}-php-api-automation-coverage-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-php-api-automation-coverage-
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-interaction
- name: Enforce line coverage
env:
XDEBUG_MODE: coverage
run: |
set -euo pipefail
include/vendor/bin/pest \
--configuration=phpunit-api-automation-tools.xml \
--coverage \
--coverage-text \
--min=100
+113 -78
View File
@@ -41,6 +41,55 @@ function getHostTemplates() : array {
return $host_templates;
}
/**
* Normalizes database IDs and builds placeholders for a prepared IN clause.
*
* @param mixed $ids A positive integer ID, an array of positive integer IDs, or false/an empty array for no filter.
*
* @return array{placeholders: string, params: array<int, int>}|false Prepared placeholders and integer parameters,
* or false when validation fails.
*/
function automation_prepare_id_list(mixed $ids) : array|false {
if ($ids === false || $ids === []) {
return [
'placeholders' => '',
'params' => []
];
}
if (!is_array($ids)) {
$ids = [$ids];
}
$params = [];
foreach ($ids as $id) {
if (is_string($id) && ctype_digit($id)) {
$normalized_id = ltrim($id, '0');
$normalized_id = $normalized_id === '' ? '0' : $normalized_id;
$max_id = (string) PHP_INT_MAX;
if (strlen($normalized_id) > strlen($max_id) ||
(strlen($normalized_id) === strlen($max_id) && strcmp($normalized_id, $max_id) > 0)) {
return false;
}
}
if (!(is_int($id) || (is_string($id) && ctype_digit($id))) || (int) $id < 1) {
return false;
}
$params[(int) $id] = (int) $id;
}
$params = array_values($params);
return [
'placeholders' => implode(', ', array_fill(0, count($params), '?')),
'params' => $params
];
}
/**
* Retrieves hosts based on their description.
*
@@ -49,32 +98,23 @@ function getHostTemplates() : array {
* @return mixed - Returns an array of hosts that match the given description, or false on failure.
*/
function getHostsByDescription(mixed $hostTemplateIds = false) : mixed {
$hosts = [];
$hosts = [];
$filter = automation_prepare_id_list($hostTemplateIds);
if ($hostTemplateIds !== false) {
if (!is_array($hostTemplateIds)) {
$hostTemplateIds = [$hostTemplateIds];
}
if ($filter === false) {
return false;
}
if ($hostTemplateIds !== false && cacti_sizeof($hostTemplateIds)) {
foreach ($hostTemplateIds as $id) {
if (!is_numeric($id)) {
return false;
}
}
$sql_where = 'WHERE ht.id IN (' . implode(',', $hostTemplateIds) . ')';
if ($filter['placeholders'] !== '') {
$sql_where = 'WHERE h.host_template_id IN (' . $filter['placeholders'] . ')';
} else {
$sql_where = '';
}
$tmpArray = db_fetch_assoc("SELECT h.id, h.description
$tmpArray = db_fetch_assoc_prepared("SELECT h.id, h.description
FROM host AS h
INNER JOIN host_template AS ht
ON h.host_template_id = ht.id
$sql_where
ORDER BY h.description");
ORDER BY h.description", $filter['params']);
if ($tmpArray !== false && cacti_sizeof($tmpArray)) {
foreach ($tmpArray as $tmp) {
@@ -112,32 +152,23 @@ function getSites() : array {
* @return mixed - Returns an array of hosts if successful, or false on failure.
*/
function getHosts(mixed $hostTemplateIds = false) : mixed {
$hosts = [];
$hosts = [];
$filter = automation_prepare_id_list($hostTemplateIds);
if ($hostTemplateIds !== false) {
if (!is_array($hostTemplateIds)) {
$hostTemplateIds = [$hostTemplateIds];
}
if ($filter === false) {
return false;
}
if ($hostTemplateIds !== false && cacti_sizeof($hostTemplateIds)) {
foreach ($hostTemplateIds as $id) {
if (!is_numeric($id)) {
return false;
}
}
$sql_where = 'WHERE ht.id IN (' . implode(',', $hostTemplateIds) . ')';
if ($filter['placeholders'] !== '') {
$sql_where = 'WHERE h.host_template_id IN (' . $filter['placeholders'] . ')';
} else {
$sql_where = '';
}
$tmpArray = db_fetch_assoc("SELECT h.id, h.hostname, h.description, h.host_template_id
$tmpArray = db_fetch_assoc_prepared("SELECT h.id, h.hostname, h.description, h.host_template_id
FROM host AS h
LEFT JOIN host_template AS ht
ON h.host_template_id = ht.id
$sql_where
ORDER BY h.id");
ORDER BY h.id", $filter['params']);
if ($tmpArray !== false && cacti_sizeof($tmpArray)) {
foreach ($tmpArray as $host) {
@@ -359,31 +390,24 @@ function getGraphTemplates() : array {
*/
function getGraphTemplatesByHostTemplate(mixed $host_template_ids = false) : mixed {
$graph_templates = [];
$filter = automation_prepare_id_list($host_template_ids);
if ($host_template_ids !== false) {
if (!is_array($host_template_ids)) {
$host_template_ids = [$host_template_ids];
}
if ($filter === false) {
return false;
}
if ($host_template_ids !== false && cacti_sizeof($host_template_ids)) {
foreach ($host_template_ids as $id) {
if (!is_numeric($id)) {
return false;
}
}
$sql_where = 'WHERE htg.host_template_id IN (' . implode(',', $host_template_ids) . ')';
if ($filter['placeholders'] !== '') {
$sql_where = 'WHERE htg.host_template_id IN (' . $filter['placeholders'] . ')';
} else {
$sql_where = '';
}
$tmpArray = db_fetch_assoc_prepared("SELECT htg.graph_template_id AS id, gt.name AS name
$tmpArray = db_fetch_assoc_prepared("SELECT DISTINCT htg.graph_template_id AS id, gt.name AS name
FROM host_template_graph AS htg
LEFT JOIN graph_templates AS gt
INNER JOIN graph_templates AS gt
ON htg.graph_template_id = gt.id
$sql_where
ORDER by gt.name ASC");
ORDER by gt.name ASC", $filter['params']);
if (cacti_sizeof($tmpArray)) {
foreach ($tmpArray as $t) {
@@ -684,23 +708,41 @@ function displayTrees(bool $quietMode = false) : void {
function displayTreeNodes(int $tree_id, string $nodeType = '', int $parentNode = 0, bool $quietMode = false) : void {
global $tree_sort_types, $tree_item_types, $host_group_types;
$nodes = db_fetch_assoc_prepared('SELECT gti.id, gti.parent, gti.local_graph_id, gti.title,
gti.host_id, gti.host_grouping_type, gti.sort_children_type,
gtg.title_cache AS graph_title, h.hostname AS host_name
FROM graph_tree_items AS gti
LEFT JOIN graph_templates_graph AS gtg
ON gtg.local_graph_id = gti.local_graph_id
LEFT JOIN host AS h
ON h.id = gti.host_id
WHERE gti.graph_tree_id = ?
ORDER BY gti.parent, gti.position, gti.id', [$tree_id]);
$nodesByParent = [];
foreach ($nodes ?: [] as $node) {
$nodesByParent[(int) ($node['parent'] ?? 0)][] = $node;
}
$visited = [];
if ($parentNode == 0) {
if (!$quietMode) {
print 'Known Tree Nodes: (type, id, parentid, title, attribs)' . PHP_EOL;
}
}
$parentID = 0;
$render = function (int $parent) use (&$render, &$visited, $nodesByParent, $nodeType, $tree_sort_types, $tree_item_types, $host_group_types) : void {
foreach ($nodesByParent[$parent] ?? [] as $node) {
$nodeId = (int) $node['id'];
$nodes = db_fetch_assoc_prepared('SELECT id, local_graph_id, title,
host_id, host_grouping_type, sort_children_type
FROM graph_tree_items
WHERE graph_tree_id = ?
AND parent = ?
ORDER BY position', [$tree_id, $parentNode]);
if (isset($visited[$nodeId])) {
continue;
}
$visited[$nodeId] = true;
if (cacti_sizeof($nodes)) {
foreach ($nodes as $node) {
// taken from tree.php, function item_edit()
$current_type = TREE_ITEM_TYPE_HEADER;
@@ -718,10 +760,10 @@ function displayTreeNodes(int $tree_id, string $nodeType = '', int $parentNode =
print $tree_item_types[$current_type] . "\t";
print $node['id'] . "\t";
if ($parentNode == 0) {
if ($parent == 0) {
print "N/A\t";
} else {
print $parentNode . "\t";
print $parent . "\t";
}
print $node['title'] . "\t";
@@ -729,7 +771,7 @@ function displayTreeNodes(int $tree_id, string $nodeType = '', int $parentNode =
print PHP_EOL;
}
displayTreeNodes($tree_id, $nodeType, $node['id'], $quietMode);
$render($nodeId);
break;
case TREE_ITEM_TYPE_GRAPH:
@@ -737,18 +779,13 @@ function displayTreeNodes(int $tree_id, string $nodeType = '', int $parentNode =
print $tree_item_types[$current_type] . "\t";
print $node['id'] . "\t";
if ($parentNode == 0) {
if ($parent == 0) {
print "N/A\t";
} else {
print $parentNode . "\t";
print $parent . "\t";
}
// fetch the title for that graph
$graph_title = db_fetch_cell_prepared('SELECT gtg.title_cache AS name
FROM graph_templates_graph AS gtg
WHERE gtg.local_graph_id = ?', [$node['local_graph_id']]);
print $graph_title . "\t";
print $node['graph_title'] . "\t";
print PHP_EOL;
}
@@ -758,15 +795,13 @@ function displayTreeNodes(int $tree_id, string $nodeType = '', int $parentNode =
print $tree_item_types[$current_type] . "\t";
print $node['id'] . "\t";
if ($parentNode == 0) {
if ($parent == 0) {
print "N/A\t";
} else {
print $parentNode . "\t";
print $parent . "\t";
}
$name = db_fetch_cell_prepared('SELECT hostname FROM host WHERE id = ?', [$node['host_id']]);
print $name . "\t";
print $node['host_name'] . "\t";
print $host_group_types[$node['host_grouping_type']] . "\t";
print PHP_EOL;
}
@@ -774,7 +809,9 @@ function displayTreeNodes(int $tree_id, string $nodeType = '', int $parentNode =
break;
}
}
}
};
$render($parentNode);
if ($parentNode == 0) {
if (!$quietMode) {
@@ -885,7 +922,7 @@ function displayUsers(bool $quietMode = false) : void {
*
* @return void
*/
function displayGroups(bool $quietMode = false) {
function displayGroups(bool $quietMode = false) : void {
if (!$quietMode) {
print 'Known Groups: (id, name, description)' . PHP_EOL;
}
@@ -905,6 +942,4 @@ function displayGroups(bool $quietMode = false) {
if (!$quietMode) {
print PHP_EOL;
}
exit(1);
}
+17
View File
@@ -0,0 +1,17 @@
<phpunit bootstrap="include/vendor/autoload.php" beStrictAboutOutputDuringTests="false">
<php>
<ini name="error_reporting" value="-1"/>
<env name="CACTI_TEST_BOOTSTRAP" value="1"/>
</php>
<source>
<include>
<file>lib/api_automation_tools.php</file>
</include>
</source>
<testsuites>
<testsuite name="API automation tools coverage">
<file>tests/Coverage/ApiAutomationToolsCoverageTest.php</file>
<file>tests/Unit/AutomationPreparedInClauseTest.php</file>
</testsuite>
</testsuites>
</phpunit>
@@ -0,0 +1,188 @@
<?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 |
+-------------------------------------------------------------------------+
*/
if (!defined('TREE_ITEM_TYPE_HEADER')) {
define('TREE_ITEM_TYPE_HEADER', 1);
}
if (!defined('TREE_ITEM_TYPE_GRAPH')) {
define('TREE_ITEM_TYPE_GRAPH', 2);
}
if (!defined('TREE_ITEM_TYPE_HOST')) {
define('TREE_ITEM_TYPE_HOST', 3);
}
$GLOBALS['automation_assoc_results'] = [];
$GLOBALS['automation_cell_results'] = [];
$GLOBALS['automation_queries'] = [];
$GLOBALS['tree_sort_types'] = [1 => 'Manual'];
$GLOBALS['tree_item_types'] = [1 => 'Header', 2 => 'Graph', 3 => 'Host'];
$GLOBALS['host_group_types'] = [1 => 'Graph Template'];
if (!function_exists('cacti_sizeof')) {
function cacti_sizeof(mixed $value) : int {
return is_array($value) ? count($value) : 0;
}
}
function db_fetch_assoc(string $sql) : array|false {
$GLOBALS['automation_queries'][] = [$sql, []];
return array_shift($GLOBALS['automation_assoc_results']);
}
function db_fetch_assoc_prepared(string $sql, array $params = []) : array|false {
$GLOBALS['automation_queries'][] = [$sql, $params];
return array_shift($GLOBALS['automation_assoc_results']);
}
function db_fetch_cell_prepared(string $sql, array $params = []) : mixed {
$GLOBALS['automation_queries'][] = [$sql, $params];
return array_shift($GLOBALS['automation_cell_results']);
}
require_once dirname(__DIR__, 2) . '/lib/api_automation_tools.php';
beforeEach(function () : void {
$GLOBALS['automation_assoc_results'] = [];
$GLOBALS['automation_cell_results'] = [];
$GLOBALS['automation_queries'] = [];
});
test('automation lookup helpers map database rows and bind optional filters', function () : void {
$GLOBALS['automation_assoc_results'] = [
[['id' => 2, 'name' => 'Linux']],
[['id' => 3, 'description' => 'router-a']],
[['id' => 4, 'name' => 'DC']],
[['id' => 5, 'hostname' => 'host-a', 'description' => 'Host A', 'host_template_id' => 2]],
[['data_template_id' => 6, 'name' => 'community', 'default' => 'public', 'description' => 'Community']],
[['id' => 7, 'hostname' => '192.0.2.7']],
[['field_name' => 'ifName']],
[['field_value' => 'eth0']],
[['id' => 8, 'name' => 'Interfaces']],
[['id' => 9, 'name' => 'Interface Statistics']],
[['id' => 10, 'name' => 'Traffic']],
[['id' => 11, 'name' => 'Traffic']],
[],
[],
[],
[],
[]
];
expect(getHostTemplates())->toBe([0 => 'None', 2 => 'Linux'])
->and(getHostsByDescription([2]))->toBe(['router-a' => 3])
->and(getSites())->toBe([4 => ['id' => 4, 'name' => 'DC']])
->and(getHosts([2]))->toBe([5 => ['id' => 5, 'hostname' => 'host-a', 'description' => 'Host A', 'host_template_id' => 2]])
->and(getInputFields(10))->toHaveKey('6:community')
->and(getAddresses())->toBe(['192.0.2.7' => 7])
->and(getSNMPFields(5, 8))->toBe(['ifName' => 1])
->and(getSNMPValues(5, 'ifName', 8))->toBe(['eth0' => 1])
->and(getSNMPQueries())->toBe([8 => 'Interfaces'])
->and(getSNMPQueryTypes(8))->toBe([9 => 'Interface Statistics'])
->and(getGraphTemplates())->toBe([10 => 'Traffic'])
->and(getGraphTemplatesByHostTemplate([2]))->toBe([11 => 'Traffic'])
->and(getHostsByDescription())->toBe([])
->and(getHosts())->toBe([])
->and(getGraphTemplatesByHostTemplate())->toBe([])
->and(getSNMPFields(5))->toBe([])
->and(getSNMPValues(5, 'ifName'))->toBe([])
->and(getHostsByDescription(['invalid']))->toBeFalse()
->and(getHosts(['invalid']))->toBeFalse()
->and(getGraphTemplatesByHostTemplate(['invalid']))->toBeFalse();
expect($GLOBALS['automation_queries'][1][1])->toBe([2])
->and($GLOBALS['automation_queries'][6][1])->toBe([5, 8])
->and($GLOBALS['automation_queries'][7][1])->toBe([5, 'ifName', 8]);
expect(automation_prepare_id_list(2))->toBe(['placeholders' => '?', 'params' => [2]])
->and(automation_prepare_id_list('0002'))->toBe(['placeholders' => '?', 'params' => [2]])
->and(automation_prepare_id_list([2, '02', 3]))->toBe(['placeholders' => '?, ?', 'params' => [2, 3]])
->and(automation_prepare_id_list((string) PHP_INT_MAX . '0'))->toBeFalse();
});
test('automation display helpers render every supported record type', function () : void {
$GLOBALS['automation_assoc_results'] = [
[['snmp_community' => 'public']],
[['id' => 1, 'sort_type' => 1, 'name' => 'Main']],
[
['id' => 1, 'parent' => 0, 'local_graph_id' => 0, 'title' => 'Network', 'host_id' => 0, 'host_grouping_type' => 1, 'sort_children_type' => 1, 'graph_title' => null, 'host_name' => null],
['id' => 2, 'parent' => 0, 'local_graph_id' => 22, 'title' => '', 'host_id' => 0, 'host_grouping_type' => 1, 'sort_children_type' => 1, 'graph_title' => 'Root Graph', 'host_name' => null],
['id' => 3, 'parent' => 0, 'local_graph_id' => 0, 'title' => '', 'host_id' => 33, 'host_grouping_type' => 1, 'sort_children_type' => 1, 'graph_title' => null, 'host_name' => 'router-a'],
['id' => 4, 'parent' => 1, 'local_graph_id' => 0, 'title' => 'Subnet', 'host_id' => 0, 'host_grouping_type' => 1, 'sort_children_type' => 1, 'graph_title' => null, 'host_name' => null],
['id' => 5, 'parent' => 1, 'local_graph_id' => 44, 'title' => '', 'host_id' => 0, 'host_grouping_type' => 1, 'sort_children_type' => 1, 'graph_title' => 'Nested Graph', 'host_name' => null],
['id' => 6, 'parent' => 1, 'local_graph_id' => 0, 'title' => '', 'host_id' => 66, 'host_grouping_type' => 1, 'sort_children_type' => 1, 'graph_title' => null, 'host_name' => 'router-b'],
['id' => 1, 'parent' => 1, 'local_graph_id' => 0, 'title' => 'Cycle', 'host_id' => 0, 'host_grouping_type' => 1, 'sort_children_type' => 1, 'graph_title' => null, 'host_name' => null]
],
[['id' => 1, 'steps' => 1, 'rows' => 600, 'name' => 'Daily']],
[['id' => 1, 'name' => 'Traffic', 'template_name' => 'Interface']],
[['id' => 1, 'username' => 'admin', 'full_name' => 'Administrator']],
[['id' => 1, 'name' => 'Operators', 'description' => 'Ops']]
];
ob_start();
displayQueryTypes([1 => 'Interface']);
displayHostTemplates([2 => 'Linux']);
displayCommunities();
displaySNMPFields(['ifName' => 1], 5);
displaySNMPValues(['eth0' => 1], 5, 'ifName');
displaySNMPQueries([8 => 'Interfaces']);
displayInputFields([['data_template_id' => 6, 'name' => 'community', 'default' => 'public', 'description' => 'Community']]);
displayGraphTemplates([10 => 'Traffic']);
displayHosts([['id' => 5, 'hostname' => 'host-a', 'host_template_id' => 2, 'description' => 'Host A']]);
displaySites([['id' => 4, 'name' => 'DC']]);
displayTrees();
displayTreeNodes(1);
displayRRAs();
displayHostGraphs(5);
displayUsers();
displayGroups();
$output = ob_get_clean();
expect($output)->toContain('Known SNMP Query Types')
->toContain('public')
->toContain('Nested Graph')
->toContain('Root Graph')
->toContain('router-a')
->toContain('Daily')
->toContain('Administrator')
->toContain('Operators');
});
test('quiet and empty display inputs produce no output', function () : void {
$GLOBALS['automation_assoc_results'] = [false, [], [], [], [], [], []];
ob_start();
displayQueryTypes(false, true);
displayHostTemplates(false, true);
displayCommunities(true);
displaySNMPFields(false, 1, true);
displaySNMPValues(false, 1, 'field', true);
displaySNMPQueries(false, true);
displayInputFields(false, true);
displayGraphTemplates(false, true);
displayHosts(false, true);
displaySites(false, true);
displayTrees(true);
displayTreeNodes(1, 'graph', 1, true);
displayRRAs(true);
displayHostGraphs(1, true);
displayUsers(true);
displayGroups(true);
$output = ob_get_clean();
expect($output)->toBe('');
});
@@ -0,0 +1,65 @@
<?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 |
+-------------------------------------------------------------------------+
*/
if (!function_exists('cacti_sizeof')) {
/**
* Returns the size of an array test value.
*
* @param mixed $value Value to count.
*
* @return int Number of elements.
*/
function cacti_sizeof(mixed $value) : int {
return is_array($value) ? count($value) : 0;
}
}
require_once dirname(__DIR__, 2) . '/lib/api_automation_tools.php';
require_once dirname(__DIR__) . '/Helpers/IsolatedProbe.php';
test('ID list normalization is strict and fail closed', function () : void {
expect(cacti_sizeof(new ArrayObject([1])))->toBe(0)
->and(automation_prepare_id_list(false))->toBe(['placeholders' => '', 'params' => []])
->and(automation_prepare_id_list([]))->toBe(['placeholders' => '', 'params' => []])
->and(automation_prepare_id_list('7'))->toBe(['placeholders' => '?', 'params' => [7]])
->and(automation_prepare_id_list([2, '9']))->toBe(['placeholders' => '?, ?', 'params' => [2, 9]])
->and(automation_prepare_id_list('1e3'))->toBeFalse()
->and(automation_prepare_id_list('1 OR 1=1'))->toBeFalse()
->and(automation_prepare_id_list((string) PHP_INT_MAX))->toBe(['placeholders' => '?', 'params' => [PHP_INT_MAX]])
->and(automation_prepare_id_list((string) PHP_INT_MAX . '0'))->toBeFalse()
->and(automation_prepare_id_list(str_repeat('9', strlen((string) PHP_INT_MAX) + 1)))->toBeFalse()
->and(automation_prepare_id_list(0))->toBeFalse()
->and(automation_prepare_id_list(-1))->toBeFalse()
->and(automation_prepare_id_list(1.5))->toBeFalse();
});
test('all three host-template filters bind dynamic IN clause values', function () : void {
$verdict = cacti_test_isolated_probe(dirname(__DIR__) . '/fixtures/automation_prepared_in_clause_probe.php');
$queries = $verdict['queries'];
expect($queries)->toHaveCount(3)
->and($queries[0][0])->toContain('h.host_template_id IN (?, ?)')
->and($queries[0][1])->toBe([1, 2])
->and($queries[1][0])->toContain('h.host_template_id IN (?)')
->and($queries[1][1])->toBe([3])
->and($queries[2][0])->toContain('htg.host_template_id IN (?, ?)')
->and($queries[2][1])->toBe([4, 5]);
});
test('invalid ID lists never reach the database', function () : void {
$verdict = cacti_test_isolated_probe(dirname(__DIR__) . '/fixtures/automation_prepared_in_clause_probe.php');
expect($verdict['invalid_results'])->toBe([false, false, false])
->and($verdict['invalid_query_count'])->toBe(0);
});
+63
View File
@@ -0,0 +1,63 @@
<?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 |
+-------------------------------------------------------------------------+
*/
$queries = [];
/**
* Captures a prepared automation query without requiring a database.
*
* @param string $sql Prepared SQL text.
* @param array $params Bound query parameters.
*
* @return array Empty result set.
*/
function db_fetch_assoc_prepared(string $sql, array $params = []) : array {
global $queries;
$queries[] = [$sql, $params];
return [];
}
/**
* Returns the size of an array probe value.
*
* @param mixed $value Value to count.
*
* @return int Number of elements.
*/
function cacti_sizeof(mixed $value) : int {
return is_array($value) ? count($value) : 0;
}
require_once dirname(__DIR__, 2) . '/lib/api_automation_tools.php';
getHostsByDescription([1, '2']);
getHosts(3);
getGraphTemplatesByHostTemplate(['4', 5]);
$valid_queries = $queries;
$queries = [];
$invalid_results = [
getHostsByDescription([1, 'bad']),
getHosts('1e3'),
getGraphTemplatesByHostTemplate('1 OR 1=1'),
];
print json_encode([
'queries' => $valid_queries,
'invalid_results' => $invalid_results,
'invalid_query_count' => count($queries),
], JSON_THROW_ON_ERROR);