Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions tests/Helpers/FakeMySQLPDO.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?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 |
+-------------------------------------------------------------------------+
*/

declare(strict_types=1);

/**
* Test-only PDO decorator that translates MySQL-specific syntax in
* lib/database.php queries to a form sqlite::memory: can execute.
* Only the surface used by the DB unit tests is implemented.
*
* The handle uses PDO::ERRMODE_EXCEPTION, so any SQL that the translate()
* pass does not rewrite into valid SQLite syntax will raise PDOException
* from prepare()/query()/exec(). Tests that exercise unsupported syntax
* must catch or expect-throws explicitly.
*/
Comment thread
somethingwithproof marked this conversation as resolved.
Comment thread
somethingwithproof marked this conversation as resolved.
class FakeMySQLPDO extends PDO {
public function __construct() {
parent::__construct('sqlite::memory:');
$this->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
}

public function prepare(string $query, array $options = []): PDOStatement|false {
return parent::prepare($this->translate($query), $options);
}

public function query(string $statement, ?int $fetchMode = null, mixed ...$fetchModeArgs): PDOStatement|false {
$translated = $this->translate($statement);
return $fetchMode === null
? parent::query($translated)
: parent::query($translated, $fetchMode, ...$fetchModeArgs);
}

public function exec(string $statement): int|false {
return parent::exec($this->translate($statement));
}

private function translate(string $sql): string {
$trim = ltrim($sql);

// SHOW TABLES LIKE '...'
if (preg_match('/^SHOW\s+TABLES\s+LIKE\s+(.+)$/is', $trim, $m)) {
return "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE " . trim($m[1], '; ');
}
// SHOW TABLES (no LIKE)
if (preg_match('/^SHOW\s+TABLES\b/i', $trim)) {
return "SELECT name FROM sqlite_master WHERE type='table'";
}
// SHOW COLUMNS FROM table (and "SHOW columns FROM table LIKE 'col'")
if (preg_match('/^SHOW\s+COLUMNS\s+FROM\s+`?([A-Za-z0-9_]+)`?(?:\s+LIKE\s+(.+))?\s*;?\s*$/is', $trim, $m)) {
$table = $m[1];
$like = isset($m[2]) ? trim($m[2], "'\" ;") : null;
// sqlite pragma returns rows with: cid, name, type, notnull, dflt_value, pk
// db_column_exists / db_get_table_column_types only need: Field (name), Type
// Use a SELECT over pragma_table_info to project MySQL-shaped column names.
$where = $like !== null ? ' WHERE name = ' . $this->quote($like) : '';
return "SELECT name AS Field, type AS Type, CASE WHEN \"notnull\" = 0 THEN 'YES' ELSE 'NO' END AS \"Null\", '' AS Collation, '' AS Key, dflt_value AS \"Default\", '' AS Extra FROM pragma_table_info('$table')$where";
}
// INSERT ... ON DUPLICATE KEY UPDATE ... -> INSERT OR REPLACE INTO ...
if (preg_match('/^INSERT\s+INTO\s+`?([A-Za-z0-9_]+)`?\s*\(([^)]+)\)\s*VALUES\s*(.+?)\s+ON\s+DUPLICATE\s+KEY\s+UPDATE\b.*$/is', $trim, $m)) {
return "INSERT OR REPLACE INTO `{$m[1]}` ({$m[2]}) VALUES {$m[3]}";
}
// SHOW INDEXES FROM table
if (preg_match('/^SHOW\s+(?:INDEX|INDEXES|KEYS)\s+FROM\s+`?([A-Za-z0-9_]+)`?\s*;?\s*$/i', $trim, $m)) {
return "SELECT name AS Key_name FROM sqlite_master WHERE type='index' AND tbl_name='{$m[1]}'";
}
return $sql;
}
}
54 changes: 54 additions & 0 deletions tests/Unit/DbConnectRetryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?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 |
+-------------------------------------------------------------------------+
*/

require_once dirname(__DIR__) . '/Helpers/UnitStubs.php';
Comment thread
somethingwithproof marked this conversation as resolved.
require_once dirname(__DIR__, 2) . '/include/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/lib/database.php';

it('returns false without throwing when MySQL is unreachable', function () {
// db_connect_real() short-circuits and returns false when the
// pdo_mysql extension is not loaded (PDO::MYSQL_ATTR_FOUND_ROWS is
// undefined), which would let this test pass without exercising the
// retry path. Skip explicitly so green output reflects real coverage.
if (!extension_loaded('pdo_mysql')) {
test()->markTestSkipped('pdo_mysql extension not loaded; retry path cannot be exercised.');
}

// 127.0.0.1:1 is reserved (tcpmux) and effectively guaranteed closed
// on dev hosts. The function loops $retries+1 times and applies a 2s
// PDO::ATTR_TIMEOUT internally; with retries=1 the worst-case wall
// time is ~4s for a refused-connection failure.
$start = microtime(true);

$result = db_connect_real(
'127.0.0.1',
'invalid_user',
'invalid_pass',
'invalid_db',
'mysql',
1,
1
);

$elapsed = microtime(true) - $start;

if ($elapsed > 10) {
test()->markTestSkipped(
sprintf('db_connect_real took %.1fs to fail; environment may be '
. 'rerouting 127.0.0.1:1.', $elapsed)
);
}

expect($result)->toBe(false);
});
168 changes: 168 additions & 0 deletions tests/Unit/DbExecutePreparedTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
<?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 |
+-------------------------------------------------------------------------+
*/

require_once dirname(__DIR__) . '/Helpers/UnitStubs.php';
Comment thread
somethingwithproof marked this conversation as resolved.
require_once dirname(__DIR__, 2) . '/include/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/lib/database.php';

// One test below remains skipped: 'fetches a row by primary key as an
// associative array'. db_fetch_row_return at lib/database.php:836 gates
// the result on PDOStatement::rowCount(), and the PDO sqlite driver
// always returns 0 from rowCount() on a SELECT (PDO behaviour, not a
// Cacti bug). The same insert/select path is covered by the sibling
// 'fetches a single row via db_fetch_assoc_prepared by primary key'
// case, which uses fetchAll() and is unaffected by the rowCount quirk.

beforeEach(function () {
$this->conn = new PDO('sqlite::memory:');
// Default ERRMODE_SILENT keeps prepare() failures from bubbling up as
// exceptions; lib/database.php expects driver-level error reporting,
// not exceptions, on prepare().
$this->conn->exec('CREATE TABLE host (id INTEGER PRIMARY KEY AUTOINCREMENT, hostname TEXT NOT NULL, disabled TEXT DEFAULT \'\')');
$this->conn->exec('CREATE TABLE settings (name TEXT PRIMARY KEY, value TEXT)');
});

it('executes a literal INSERT and returns truthy', function () {
$result = db_execute("INSERT INTO host (hostname) VALUES ('h1')", true, $this->conn);
expect($result)->toBeTruthy();
});

it('fetches a single cell value via prepared parameters', function () {
db_execute("INSERT INTO host (hostname) VALUES ('h1')", true, $this->conn);

$cell = db_fetch_cell_prepared(
'SELECT hostname FROM host WHERE id = ?',
[1],
'',
true,
$this->conn
);

expect($cell)->toBe('h1');
});

it('fetches a row by primary key as an associative array', function () {
// db_fetch_row_return uses PDOStatement::rowCount() which the SQLite
// driver leaves at 0 for SELECT statements. The return path therefore
// emits []. db_fetch_assoc_prepared works around the rowCount quirk
// and is asserted in a sibling test below.
db_execute("INSERT INTO host (hostname) VALUES ('h1')", true, $this->conn);

$row = db_fetch_row_prepared(
'SELECT * FROM host WHERE id = ?',
[1],
true,
$this->conn
);

if ($row === []) {
test()->markTestSkipped(
'db_fetch_row_prepared relies on PDOStatement::rowCount(), which '
. 'returns 0 for SELECT under the PDO sqlite driver. The function '
. 'is exercised against MySQL in integration tests.'
);
}

expect($row)->toBeArray()
->and($row)->toHaveKey('hostname')
->and($row['hostname'])->toBe('h1');
});

it('fetches a single row via db_fetch_assoc_prepared by primary key', function () {
db_execute("INSERT INTO host (hostname) VALUES ('h1')", true, $this->conn);

$rows = db_fetch_assoc_prepared(
'SELECT * FROM host WHERE id = ?',
[1],
true,
$this->conn
);

expect($rows)->toBeArray()
->and(count($rows))->toBe(1)
->and($rows[0])->toHaveKey('hostname')
->and($rows[0]['hostname'])->toBe('h1');
});

it('fetches all rows as an associative array', function () {
db_execute("INSERT INTO host (hostname) VALUES ('h1')", true, $this->conn);
db_execute("INSERT INTO host (hostname) VALUES ('h2')", true, $this->conn);

$rows = db_fetch_assoc_prepared('SELECT * FROM host', [], true, $this->conn);

expect($rows)->toBeArray()
->and(count($rows))->toBe(2)
->and($rows[0])->toHaveKey('hostname');
});

it('reports the number of rows affected by an UPDATE', function () {
db_execute("INSERT INTO host (hostname) VALUES ('h1')", true, $this->conn);

db_execute_prepared(
'UPDATE host SET disabled = ?',
['on'],
true,
$this->conn
);

expect((int) db_affected_rows($this->conn))->toBe(1);
});

it('returns the auto-increment id of the last INSERT', function () {
db_execute("INSERT INTO host (hostname) VALUES ('h1')", true, $this->conn);

expect((int) db_fetch_insert_id($this->conn))->toBeGreaterThan(0);
});

it('binds an SQL injection payload as a literal parameter', function () {
db_execute("INSERT INTO host (hostname) VALUES ('h1')", true, $this->conn);

$cell = db_fetch_cell_prepared(
'SELECT hostname FROM host WHERE hostname = ?',
["' OR 1=1 --"],
'',
true,
$this->conn
);

// No row matches the literal payload; the function returns false on
// empty results.
expect($cell)->toBe(false);
});

it('returns the requested column when col_name is set', function () {
db_execute("INSERT INTO host (hostname) VALUES ('h1')", true, $this->conn);

$cell = db_fetch_cell_prepared(
'SELECT id, hostname FROM host WHERE id = ?',
[1],
'hostname',
true,
$this->conn
);

expect($cell)->toBe('h1');
});

it('returns false when the prepared SELECT yields no rows', function () {
$cell = db_fetch_cell_prepared(
'SELECT hostname FROM host WHERE id = ?',
[9999],
'',
true,
$this->conn
);

expect($cell)->toBe(false);
});
58 changes: 58 additions & 0 deletions tests/Unit/DbReplaceSqlSaveTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?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 |
+-------------------------------------------------------------------------+
*/

require_once dirname(__DIR__) . '/Helpers/UnitStubs.php';
Comment thread
somethingwithproof marked this conversation as resolved.
require_once dirname(__DIR__) . '/Helpers/FakeMySQLPDO.php';
require_once dirname(__DIR__, 2) . '/include/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/lib/database.php';

// FakeMySQLPDO rewrites SHOW TABLES / SHOW COLUMNS and
// INSERT ... ON DUPLICATE KEY UPDATE into the sqlite_master /
// pragma_table_info / INSERT OR REPLACE equivalents, so the real
// _db_replace, db_replace, and sql_save functions execute unmodified.

beforeEach(function () {
$this->conn = new FakeMySQLPDO();
$this->conn->exec('CREATE TABLE host (id INTEGER PRIMARY KEY AUTOINCREMENT, hostname TEXT NOT NULL, disabled TEXT DEFAULT \'\')');
$this->conn->exec('CREATE TABLE settings (name TEXT PRIMARY KEY, value TEXT)');
});

it('inserts via sql_save and returns the new auto-increment id', function () {
$id = sql_save(['hostname' => 'host1', 'disabled' => ''], 'host', 'id', true, $this->conn);

expect((int) $id)->toBe(1);

$row = $this->conn->query('SELECT hostname FROM host WHERE id = 1')->fetch(PDO::FETCH_ASSOC);
expect($row['hostname'])->toBe('host1');
});

it('updates an existing row when sql_save receives an explicit id', function () {
sql_save(['hostname' => 'host1', 'disabled' => ''], 'host', 'id', true, $this->conn);
sql_save(['id' => 1, 'hostname' => 'renamed'], 'host', 'id', true, $this->conn);

$row = $this->conn->query('SELECT hostname FROM host WHERE id = 1')->fetch(PDO::FETCH_ASSOC);
expect($row['hostname'])->toBe('renamed');
});

it('REPLACE-INTO semantics keep a single row keyed by name', function () {
// _db_replace inlines field values directly into the SQL string, so the
// caller must pre-quote string values (sql_save does this via db_qstr).
db_replace('settings', ['name' => "'opt'", 'value' => "'v1'"], 'name', $this->conn);
db_replace('settings', ['name' => "'opt'", 'value' => "'v2'"], 'name', $this->conn);

$rows = $this->conn->query('SELECT name, value FROM settings')->fetchAll(PDO::FETCH_ASSOC);
expect(count($rows))->toBe(1)
->and($rows[0]['name'])->toBe('opt')
->and($rows[0]['value'])->toBe('v2');
});
Loading
Loading