mirror of
https://github.com/Cacti/cacti.git
synced 2026-10-23 03:27:02 +09:00
feat(auth): log administrative account actions after confirmed writes (#8360)
* log user action (create, delete, locked, ..) * change log * update changelog * style(auth): apply coding standards to user action logging Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * Audit password changes only after successful account saves Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(auth): sanitize audit fields and confirm account removals Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * test: cover native account audit state transitions Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * test: synchronize native coverage fixture dispatch Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(auth): audit only confirmed account persistence Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * test: measure coverage at the exact PR revision Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> --------- Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> Co-authored-by: Petr Macek <petr.macek@kostax.cz>
This commit is contained in:
1 parent
a634323d1d
commit
930b085703
14 files changed
+1071
-3
No files matched your search
@@ -0,0 +1,87 @@
|
|||||||
|
name: Changed PHP Coverage
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '**.php'
|
||||||
|
- 'phpunit.xml'
|
||||||
|
- 'tests/e2e/**'
|
||||||
|
- 'tests/Helpers/RequestCoverage.php'
|
||||||
|
- 'tests/tools/check_changed_php_coverage.py'
|
||||||
|
- 'tests/tools/test_check_changed_php_coverage.py'
|
||||||
|
- 'tests/tools/run_request_coverage.sh'
|
||||||
|
- 'tests/tools/test_request_coverage_dispatch.py'
|
||||||
|
- '.github/workflows/pr-php-coverage.yml'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: changed-php-coverage-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
coverage:
|
||||||
|
name: Changed PHP lines >= 80%
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3
|
||||||
|
with:
|
||||||
|
tool_versions: node 22.22.2
|
||||||
|
install_args: node
|
||||||
|
- uses: ./.github/actions/setup-php-composer
|
||||||
|
with:
|
||||||
|
php-version: '8.3'
|
||||||
|
coverage: xdebug
|
||||||
|
extensions: intl, mysql, gd, ldap, gmp, xml, curl, json, mbstring
|
||||||
|
ini-values: memory_limit=2G
|
||||||
|
dependency-mode: locked-pr-coverage
|
||||||
|
install-args: --prefer-dist --no-progress --no-interaction
|
||||||
|
- name: Validate the coverage gate and include every changed PHP file
|
||||||
|
env:
|
||||||
|
BASE_COMMIT: ${{ github.event.pull_request.base.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python3 tests/tools/test_check_changed_php_coverage.py
|
||||||
|
python3 tests/tools/test_request_coverage_dispatch.py
|
||||||
|
python3 tests/tools/check_changed_php_coverage.py --base "$BASE_COMMIT" \
|
||||||
|
--prepare-config phpunit.xml --output-config phpunit-changed.xml
|
||||||
|
- name: Exercise unit and integration contracts with statement coverage
|
||||||
|
env:
|
||||||
|
XDEBUG_MODE: coverage
|
||||||
|
run: include/vendor/bin/pest --configuration=phpunit-changed.xml --testsuite=Unit,Integration --coverage-clover=coverage-changed.xml
|
||||||
|
- name: Exercise applicable browser and database contracts
|
||||||
|
env:
|
||||||
|
BASE_COMMIT: ${{ github.event.pull_request.base.sha }}
|
||||||
|
CACTI_TEST_COVERAGE: '1'
|
||||||
|
CACTI_PR_RAW_COVERAGE: /tmp/cacti-pr-coverage
|
||||||
|
CACTI_PR_COVERAGE_OUTPUT: ${{ runner.temp }}/request-coverage
|
||||||
|
run: bash tests/tools/run_request_coverage.sh
|
||||||
|
- name: Require at least 80 percent of changed executable PHP lines
|
||||||
|
env:
|
||||||
|
BASE_COMMIT: ${{ github.event.pull_request.base.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
request_args=()
|
||||||
|
if compgen -G "${{ runner.temp }}/request-coverage/*.json" > /dev/null; then
|
||||||
|
request_args=(--request-reports "${{ runner.temp }}/request-coverage")
|
||||||
|
fi
|
||||||
|
python3 tests/tools/check_changed_php_coverage.py --base "$BASE_COMMIT" \
|
||||||
|
--report coverage-changed.xml "${request_args[@]}" \
|
||||||
|
--minimum 80 --json-output coverage-changed.json
|
||||||
|
- name: Preserve coverage evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||||
|
with:
|
||||||
|
name: changed-php-coverage
|
||||||
|
path: |
|
||||||
|
coverage-changed.xml
|
||||||
|
coverage-changed.json
|
||||||
|
${{ runner.temp }}/request-coverage
|
||||||
|
if-no-files-found: warn
|
||||||
@@ -413,6 +413,7 @@ Cacti CHANGELOG
|
|||||||
-feature#7372: Add installer domain foundation types and lifecycle tests
|
-feature#7372: Add installer domain foundation types and lifecycle tests
|
||||||
-feature#7634: Add indexes for host-template and session lookup queries
|
-feature#7634: Add indexes for host-template and session lookup queries
|
||||||
-feature#7681: Add fail2ban support with a stable authentication-failure log line and a bundled filter
|
-feature#7681: Add fail2ban support with a stable authentication-failure log line and a bundled filter
|
||||||
|
-feature#8281: Log user actions (create, delete, ...)
|
||||||
|
|
||||||
1.2.32
|
1.2.32
|
||||||
-security#GHSA-fq9x-x3vf-3vf2: Substitute data-input field tokens in a single pass so a composed value cannot re-inject an escaped payload into the script command line
|
-security#GHSA-fq9x-x3vf-3vf2: Substitute data-input field tokens in a single pass so a composed value cannot re-inject an escaped payload into the script command line
|
||||||
|
|||||||
@@ -2348,6 +2348,8 @@ function _db_replace(mixed $db_conn, string $table, array $fieldArray, mixed $ke
|
|||||||
|
|
||||||
if (!$return_code) {
|
if (!$return_code) {
|
||||||
cacti_log("ERROR: SQL Save Failed for Table '$table'. SQL:'" . clean_up_lines($sql) . "'", false, 'DBCALL');
|
cacti_log("ERROR: SQL Save Failed for Table '$table'. SQL:'" . clean_up_lines($sql) . "'", false, 'DBCALL');
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return db_fetch_insert_id($db_conn);
|
return db_fetch_insert_id($db_conn);
|
||||||
@@ -2429,6 +2431,10 @@ function sql_save(array $array_items, string $table_name, mixed $key_cols = 'id'
|
|||||||
|
|
||||||
$replace_result = _db_replace($db_conn, $table_name, $array_items, $key_cols);
|
$replace_result = _db_replace($db_conn, $table_name, $array_items, $key_cols);
|
||||||
|
|
||||||
|
if ($replace_result === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// get the last AUTO_ID and return it
|
// get the last AUTO_ID and return it
|
||||||
if (!$replace_result || db_fetch_insert_id($db_conn) == '0') {
|
if (!$replace_result || db_fetch_insert_id($db_conn) == '0') {
|
||||||
if (!is_array($key_cols)) {
|
if (!is_array($key_cols)) {
|
||||||
|
|||||||
@@ -10805,3 +10805,50 @@ function validate_relative_path_within(mixed $path, string $base_dir) : mixed {
|
|||||||
|
|
||||||
return $candidate;
|
return $candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an administrative action performed on a user account.
|
||||||
|
*
|
||||||
|
* @param mixed $user Username of the affected account.
|
||||||
|
* @param mixed $action Action that was performed.
|
||||||
|
*
|
||||||
|
* @return bool Whether the message was written to the Cacti log.
|
||||||
|
*/
|
||||||
|
function log_user_action($user, $action) {
|
||||||
|
if (!is_string($user) || !is_string($action)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$clean = static function (string $value) : string {
|
||||||
|
return preg_replace('/[\x00-\x1f\x7f]/', '', $value) ?? '';
|
||||||
|
};
|
||||||
|
$user = $clean($user);
|
||||||
|
$action = $clean($action);
|
||||||
|
|
||||||
|
if ($user === '' || $action === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($_SESSION['sess_user_id'])) {
|
||||||
|
$acting_user_id = $_SESSION['sess_user_id'];
|
||||||
|
$acting_user = get_username($acting_user_id);
|
||||||
|
|
||||||
|
if (!is_string($acting_user) || $acting_user === '') {
|
||||||
|
$acting_user = 'user ID ' . $acting_user_id;
|
||||||
|
}
|
||||||
|
} elseif (PHP_SAPI === 'cli') {
|
||||||
|
$acting_user = get_execution_user();
|
||||||
|
|
||||||
|
if ($acting_user === '') {
|
||||||
|
$acting_user = 'unknown user';
|
||||||
|
}
|
||||||
|
|
||||||
|
$acting_user .= ' (CLI)';
|
||||||
|
} else {
|
||||||
|
$acting_user = 'unknown user';
|
||||||
|
}
|
||||||
|
|
||||||
|
$acting_user = $clean($acting_user);
|
||||||
|
|
||||||
|
return cacti_log("NOTE: User '$user' was $action by user '$acting_user'", false, PHP_SAPI === 'cli' ? 'AUTHC' : 'AUTH');
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
// Copyright (C) 2004-2026 The Cacti Group. GPL-2.0-or-later.
|
||||||
|
|
||||||
|
// Loaded only by the disposable E2E image when coverage is explicitly enabled.
|
||||||
|
$requestCoverageDirectory = getenv('CACTI_PR_RAW_COVERAGE');
|
||||||
|
|
||||||
|
if ($requestCoverageDirectory !== false && $requestCoverageDirectory !== '') {
|
||||||
|
if (!function_exists('xdebug_start_code_coverage')) {
|
||||||
|
throw new RuntimeException('Request coverage requires Xdebug.');
|
||||||
|
}
|
||||||
|
xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE);
|
||||||
|
register_shutdown_function(static function () use ($requestCoverageDirectory) : void {
|
||||||
|
$data = xdebug_get_code_coverage();
|
||||||
|
|
||||||
|
foreach ($GLOBALS['cacti_fixture_source_map'] ?? [] as $temporary => $original) {
|
||||||
|
foreach ($data[$temporary] ?? [] as $line => $hits) {
|
||||||
|
$data[$original][$line] = max($hits, $data[$original][$line] ?? -2);
|
||||||
|
}
|
||||||
|
unset($data[$temporary]);
|
||||||
|
}
|
||||||
|
$root = realpath(dirname(__DIR__, 2));
|
||||||
|
$hashes = [];
|
||||||
|
$htmlSpans = [];
|
||||||
|
|
||||||
|
foreach ($data as $file => $lines) {
|
||||||
|
if (!str_starts_with($file, $root . '/') || str_contains($file, '/vendor/')) {
|
||||||
|
unset($data[$file]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$hashes[$file] = hash_file('sha256', $file);
|
||||||
|
$htmlSpans[$file] = [];
|
||||||
|
|
||||||
|
// Zend records a literal HTML output opcode at the end of its block.
|
||||||
|
foreach (token_get_all(file_get_contents($file)) as $token) {
|
||||||
|
if (is_array($token) && $token[0] === T_INLINE_HTML) {
|
||||||
|
$htmlSpans[$file][] = [$token[2], $token[2] + substr_count($token[1], "\n")];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$path = $requestCoverageDirectory . '/' . getmypid() . '-' . bin2hex(random_bytes(8)) . '.json';
|
||||||
|
|
||||||
|
if (file_put_contents($path, json_encode(['root' => $root, 'coverage' => $data, 'hashes' => $hashes, 'html_spans' => $htmlSpans], JSON_THROW_ON_ERROR)) === false) {
|
||||||
|
throw new RuntimeException('Could not preserve request coverage.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -56,3 +56,44 @@ it('REPLACE-INTO semantics keep a single row keyed by name', function () {
|
|||||||
->and($rows[0]['name'])->toBe('opt')
|
->and($rows[0]['name'])->toBe('opt')
|
||||||
->and($rows[0]['value'])->toBe('v2');
|
->and($rows[0]['value'])->toBe('v2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns false for a failed update instead of the existing or stale insert id', function () {
|
||||||
|
sql_save(['hostname' => 'host1'], 'host', 'id', true, $this->conn);
|
||||||
|
$this->conn->exec("CREATE TRIGGER reject_host_save BEFORE INSERT ON host BEGIN SELECT RAISE(FAIL, 'save rejected'); END");
|
||||||
|
|
||||||
|
$result = sql_save(['id' => 1, 'hostname' => 'renamed'], 'host', 'id', true, $this->conn);
|
||||||
|
|
||||||
|
expect($result)->toBeFalse()
|
||||||
|
->and($this->conn->query('SELECT hostname FROM host WHERE id = 1')->fetchColumn())->toBe('host1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for a failed insert even when the connection has an earlier insert id', function () {
|
||||||
|
sql_save(['hostname' => 'host1'], 'host', 'id', true, $this->conn);
|
||||||
|
$this->conn->exec("CREATE TRIGGER reject_host_save BEFORE INSERT ON host BEGIN SELECT RAISE(FAIL, 'save rejected'); END");
|
||||||
|
|
||||||
|
expect(sql_save(['hostname' => 'host2'], 'host', 'id', true, $this->conn))->toBeFalse()
|
||||||
|
->and((int) $this->conn->query('SELECT COUNT(*) FROM host')->fetchColumn())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the existing key fallback after a successful write with no new insert id', function () {
|
||||||
|
$conn = new class extends FakeMySQLPDO {
|
||||||
|
public function lastInsertId(?string $name = null): string|false {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function prepare(string $query, array $options = []): PDOStatement|false {
|
||||||
|
// MySQL reports numeric column types in lowercase; SQLite's
|
||||||
|
// metadata otherwise treats this primary key as a quoted string.
|
||||||
|
if ($query === 'SHOW COLUMNS FROM host') {
|
||||||
|
$query = "SELECT name AS Field, lower(type) AS Type, 'NO' AS `Null`, '' AS `Default`, '' AS Extra FROM pragma_table_info('host')";
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::prepare($query, $options);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$conn->exec('CREATE TABLE host (id INTEGER PRIMARY KEY, hostname TEXT NOT NULL)');
|
||||||
|
$conn->exec("INSERT INTO host VALUES (1, 'host1')");
|
||||||
|
|
||||||
|
expect((int) sql_save(['id' => 1, 'hostname' => 'renamed'], 'host', 'id', true, $conn))->toBe(1)
|
||||||
|
->and($conn->query('SELECT hostname FROM host WHERE id = 1')->fetchColumn())->toBe('renamed');
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
// Copyright (C) 2026 The Cacti Group. GPL-2.0-or-later.
|
||||||
|
|
||||||
|
require dirname(__DIR__) . '/Helpers/IsolatedProbe.php';
|
||||||
|
$arguments = [];
|
||||||
|
|
||||||
|
if (getenv('CACTI_PR_XDEBUG_EXTENSION')) {
|
||||||
|
$arguments = ['-d', 'zend_extension=' . getenv('CACTI_PR_XDEBUG_EXTENSION')];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['edit', 'password', 'failed-save', 'failed-save-id', 'save-read-failure', 'save-missing', 'mismatch', 'new', 'audit-controls', 'delete', 'delete-template', 'delete-guest', 'delete-failure', 'delete-read-failure', 'delete-missing', 'enable', 'disable', 'lock', 'unlock', 'audit-invalid', 'audit-unknown', 'audit-cli', 'bulk-enable', 'bulk-disable', 'bulk-enable-failure', 'bulk-disable-failure', 'bulk-enable-read-failure', 'bulk-disable-read-failure', 'bulk-enable-missing', 'bulk-disable-missing', 'bulk-enable-noop', 'bulk-disable-noop'] as $scenario) {
|
||||||
|
$process = proc_open(array_merge([PHP_BINARY], $arguments, [__DIR__ . '/UserAdministrationAuditProbe.php', $scenario]), [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
||||||
|
$output = stream_get_contents($pipes[1]);
|
||||||
|
$error = stream_get_contents($pipes[2]);
|
||||||
|
fclose($pipes[1]);
|
||||||
|
fclose($pipes[2]);
|
||||||
|
|
||||||
|
if (proc_close($process) !== 0 || !is_array(json_decode($output, true))) {
|
||||||
|
throw new RuntimeException($scenario . ': ' . $error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
print "PASS: native account save and audit scenarios\n";
|
||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
<?php
|
||||||
|
// Copyright (C) 2026 The Cacti Group. Licensed under GPL-2.0-or-later.
|
||||||
|
namespace CactiUserAuditFixture;
|
||||||
|
use PDO;
|
||||||
|
use RuntimeException;
|
||||||
|
require dirname(__DIR__) . '/Helpers/RequestCoverage.php';
|
||||||
|
require dirname(__DIR__) . '/Helpers/SourceSlice.php';
|
||||||
|
// Run the real save handler and audit helper with an isolated SQLite save boundary.
|
||||||
|
$scenario = $argv[1] ?? 'edit';
|
||||||
|
$root = dirname(__DIR__, 2);
|
||||||
|
require $root . '/include/global_constants.php';
|
||||||
|
$pdo = new PDO('sqlite::memory:', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||||
|
$pdo->exec('CREATE TABLE user_auth (id INTEGER PRIMARY KEY, username TEXT, password TEXT, enabled TEXT, locked TEXT)');
|
||||||
|
$old_hash = password_hash('old password', PASSWORD_DEFAULT);
|
||||||
|
$statement = $pdo->prepare('INSERT INTO user_auth VALUES (10, ?, ?, ?, ?)');
|
||||||
|
$statement->execute(['target', $old_hash, 'on', '']);
|
||||||
|
$logs = [];
|
||||||
|
$messages = [];
|
||||||
|
$_SESSION = ['sess_user_id' => 1];
|
||||||
|
$request = ['save_component_user' => '1', 'id' => $scenario === 'new' ? 0 : 10, 'username' => 'target', 'realm' => 0, 'enabled' => 'on', 'locked' => '', 'password' => '', 'password_confirm' => ''];
|
||||||
|
|
||||||
|
if ($scenario === 'enable' || str_starts_with($scenario, 'bulk-enable')) {
|
||||||
|
$pdo->exec("UPDATE user_auth SET enabled = '' WHERE id = 10");
|
||||||
|
} elseif ($scenario === 'disable') {
|
||||||
|
$request['enabled'] = '';
|
||||||
|
} elseif ($scenario === 'lock') {
|
||||||
|
$request['locked'] = 'on';
|
||||||
|
} elseif ($scenario === 'unlock') {
|
||||||
|
$pdo->exec("UPDATE user_auth SET locked = 'on' WHERE id = 10");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($scenario, ['password', 'failed-save', 'failed-save-id', 'save-read-failure', 'save-missing', 'mismatch'], true)) {
|
||||||
|
$request['password'] = 'new password';
|
||||||
|
$request['password_confirm'] = $scenario === 'mismatch' ? 'different password' : 'new password';
|
||||||
|
}
|
||||||
|
|
||||||
|
function load_audit_function($file, $name) {
|
||||||
|
cacti_fixture_source_function($file, $name, __NAMESPACE__);
|
||||||
|
}
|
||||||
|
function gnrv($key, $default = '') {
|
||||||
|
return $GLOBALS['request'][$key] ?? $default;
|
||||||
|
}
|
||||||
|
function grv($key, $default = '') {
|
||||||
|
return gnrv($key, $default);
|
||||||
|
}
|
||||||
|
function gfrv($key, $default = 0) {
|
||||||
|
return (int) gnrv($key, $default);
|
||||||
|
}
|
||||||
|
function get_nfilter_request_var($key) {
|
||||||
|
return gnrv($key);
|
||||||
|
}
|
||||||
|
function isrv($key) {
|
||||||
|
return isset($GLOBALS['request'][$key]);
|
||||||
|
}
|
||||||
|
function is_error_message() {
|
||||||
|
return $GLOBALS['messages'] !== [];
|
||||||
|
}
|
||||||
|
function raise_message($message, ...$args) {
|
||||||
|
if ($message !== 1) {
|
||||||
|
$GLOBALS['messages'][] = $message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function read_config_option($key) {
|
||||||
|
return $key === 'admin_user' ? 1 : '';
|
||||||
|
}
|
||||||
|
function is_template_account($id) {
|
||||||
|
return $GLOBALS['scenario'] === 'delete-template';
|
||||||
|
}
|
||||||
|
function get_guest_account() {
|
||||||
|
return $GLOBALS['scenario'] === 'delete-guest' ? 10 : 0;
|
||||||
|
}
|
||||||
|
function input_validate_input_number(...$args) {
|
||||||
|
}
|
||||||
|
function cacti_count($value) {
|
||||||
|
return count($value);
|
||||||
|
}
|
||||||
|
function sanitize_unserialize_selected_items($value) {
|
||||||
|
return unserialize($value, ['allowed_classes' => false]);
|
||||||
|
}
|
||||||
|
function cacti_sizeof($value) {
|
||||||
|
return is_countable($value) ? count($value) : 0;
|
||||||
|
}
|
||||||
|
function compat_password_hash($password, $algorithm) {
|
||||||
|
return password_hash($password, $algorithm);
|
||||||
|
}
|
||||||
|
function form_input_validate($value, ...$args) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
function db_fetch_row_prepared($sql, $params = []) {
|
||||||
|
if ($GLOBALS['scenario'] === 'delete-read-failure' && str_contains($sql, 'SELECT id')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($sql, 'SELECT id, username, password, enabled, locked')) {
|
||||||
|
if (in_array($GLOBALS['scenario'], ['save-read-failure', 'bulk-enable-read-failure', 'bulk-disable-read-failure'], true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($GLOBALS['scenario'] === 'save-missing') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($sql, 'SELECT username, enabled') && !empty($GLOBALS['bulk_mutated']) && str_ends_with($GLOBALS['scenario'], '-read-failure')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($sql, 'realm = ?')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$statement = $GLOBALS['pdo']->prepare('SELECT * FROM user_auth WHERE id = ?');
|
||||||
|
$statement->execute([$params[0]]);
|
||||||
|
|
||||||
|
return $statement->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||||
|
}
|
||||||
|
function db_fetch_cell_prepared($sql, $params = []) {
|
||||||
|
if (str_contains($sql, 'password_history')) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$row = db_fetch_row_prepared($sql, $params);
|
||||||
|
|
||||||
|
return $row[str_contains($sql, 'SELECT password') ? 'password' : 'username'] ?? '';
|
||||||
|
}
|
||||||
|
function db_execute_prepared($sql, $params = []) {
|
||||||
|
if (str_starts_with($sql, 'UPDATE user_auth SET enabled')) {
|
||||||
|
$GLOBALS['bulk_mutated'] = true;
|
||||||
|
|
||||||
|
if (str_ends_with($GLOBALS['scenario'], '-failure') && !str_ends_with($GLOBALS['scenario'], '-read-failure')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$statement = $GLOBALS['pdo']->prepare($sql);
|
||||||
|
|
||||||
|
return $statement->execute($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($sql, 'DELETE FROM user_auth WHERE')) {
|
||||||
|
if ($GLOBALS['scenario'] === 'delete-failure') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$statement = $GLOBALS['pdo']->prepare($sql);
|
||||||
|
|
||||||
|
return $statement->execute($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function api_plugin_hook_function($hook, $save) {
|
||||||
|
return $save;
|
||||||
|
}
|
||||||
|
function sql_save($save, $table) {
|
||||||
|
if ($GLOBALS['scenario'] === 'failed-save') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The legacy production helper could return the existing ID on write
|
||||||
|
// failure. Keep that case to verify the handler also confirms persistence.
|
||||||
|
if ($GLOBALS['scenario'] === 'failed-save-id') {
|
||||||
|
return $save['id'];
|
||||||
|
}
|
||||||
|
$id = $save['id'] ?: 11;
|
||||||
|
$statement = $GLOBALS['pdo']->prepare('INSERT OR REPLACE INTO user_auth VALUES (?, ?, ?, ?, ?)');
|
||||||
|
$statement->execute([$id, $save['username'], $save['password'], $save['enabled'], $save['locked']]);
|
||||||
|
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
function get_username($id) {
|
||||||
|
if ($id === 1) {
|
||||||
|
return $GLOBALS['scenario'] === 'audit-controls' ? "admin\r\n\x00\x1b\x7f" : 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
return db_fetch_cell_prepared('SELECT username FROM user_auth WHERE id = ?', [$id]);
|
||||||
|
}
|
||||||
|
function cacti_log($message, ...$args) {
|
||||||
|
$GLOBALS['logs'][] = $message;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function reset_user_perms($id) {
|
||||||
|
}
|
||||||
|
|
||||||
|
load_audit_function($root . '/user_admin.php', 'form_save');
|
||||||
|
load_audit_function($root . '/lib/functions.php', 'log_user_action');
|
||||||
|
load_audit_function($root . '/lib/functions.php', 'get_execution_user');
|
||||||
|
|
||||||
|
if (str_starts_with($scenario, 'audit-') && $scenario !== 'audit-controls') {
|
||||||
|
if ($scenario === 'audit-invalid') {
|
||||||
|
foreach ([[[], 'edited'], ['target', []], ["\r\n", 'edited'], ['target', "\x00"]] as [$user, $action]) {
|
||||||
|
if (log_user_action($user, $action) !== false) {
|
||||||
|
throw new RuntimeException('Invalid audit input was logged');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$_SESSION = $scenario === 'audit-cli' ? [] : ['sess_user_id' => 404];
|
||||||
|
log_user_action('target', 'edited');
|
||||||
|
}
|
||||||
|
print json_encode(['logs' => $logs], JSON_THROW_ON_ERROR) . "\n";
|
||||||
|
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($scenario === 'audit-controls') {
|
||||||
|
log_user_action("target\r\n\x00\x1b\x7f", "edited\r\n\x00\x1b\x7f");
|
||||||
|
print json_encode(['logs' => $logs], JSON_THROW_ON_ERROR) . "\n";
|
||||||
|
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($scenario, 'delete') || str_starts_with($scenario, 'bulk-')) {
|
||||||
|
load_audit_function($root . '/user_admin.php', 'form_actions');
|
||||||
|
load_audit_function($root . '/lib/auth.php', 'user_remove');
|
||||||
|
load_audit_function($root . '/lib/auth.php', 'user_enable');
|
||||||
|
load_audit_function($root . '/lib/auth.php', 'user_disable');
|
||||||
|
$request = ['selected_items' => serialize([10]), 'drp_action' => str_starts_with($scenario, 'bulk-enable') ? '3' : (str_starts_with($scenario, 'bulk-disable') ? '4' : '1')];
|
||||||
|
|
||||||
|
if ($scenario === 'delete-missing') {
|
||||||
|
$pdo->exec('DELETE FROM user_auth WHERE id = 10');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_ends_with($scenario, '-missing')) {
|
||||||
|
$pdo->exec('DELETE FROM user_auth WHERE id = 10');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($scenario === 'bulk-enable-noop') {
|
||||||
|
$pdo->exec("UPDATE user_auth SET enabled = 'on' WHERE id = 10");
|
||||||
|
} elseif ($scenario === 'bulk-disable-noop') {
|
||||||
|
$pdo->exec("UPDATE user_auth SET enabled = '' WHERE id = 10");
|
||||||
|
}
|
||||||
|
register_shutdown_function(static function () use ($pdo, &$logs, &$messages): void {
|
||||||
|
print json_encode(['logs' => $logs, 'remaining' => (int) $pdo->query('SELECT COUNT(*) FROM user_auth WHERE id = 10')->fetchColumn(), 'enabled' => $pdo->query('SELECT enabled FROM user_auth WHERE id = 10')->fetchColumn(), 'errors' => $messages], JSON_THROW_ON_ERROR) . "\n";
|
||||||
|
});
|
||||||
|
form_actions();
|
||||||
|
}
|
||||||
|
form_save();
|
||||||
|
$saved = $pdo->query('SELECT password FROM user_auth WHERE id = ' . ($scenario === 'new' ? 11 : 10))->fetchColumn();
|
||||||
|
print json_encode(['logs' => $logs, 'password_changed' => $saved !== $old_hash, 'new_password_matches' => password_verify('new password', $saved), 'errors' => $messages], JSON_THROW_ON_ERROR) . "\n";
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
// Copyright (C) 2026 The Cacti Group. Licensed under GPL-2.0-or-later.
|
||||||
|
require_once dirname(__DIR__) . '/Helpers/IsolatedProbe.php';
|
||||||
|
|
||||||
|
test('account save audits a password change only after it is persisted', function (string $scenario, bool $changed) {
|
||||||
|
$r = cacti_test_isolated_probe(dirname(__DIR__) . '/fixtures/UserAdministrationAuditProbe.php', [$scenario]);
|
||||||
|
$events = array_values(array_filter($r['logs'], static fn ($log) => str_contains($log, 'changed password')));
|
||||||
|
expect($events)->toHaveCount($changed ? 1 : 0)->and($r['password_changed'])->toBe($changed);
|
||||||
|
|
||||||
|
if ($changed) {
|
||||||
|
expect($r['new_password_matches'])->toBeTrue();
|
||||||
|
}
|
||||||
|
})->with([
|
||||||
|
'ordinary edit' => ['edit', false],
|
||||||
|
'password change' => ['password', true],
|
||||||
|
'failed database save' => ['failed-save', false],
|
||||||
|
'password mismatch' => ['mismatch', false]
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('saved enabled and locked transitions identify the affected account and actor', function ($scenario, $action) {
|
||||||
|
$r = cacti_test_isolated_probe(dirname(__DIR__) . '/fixtures/UserAdministrationAuditProbe.php', [$scenario]);
|
||||||
|
expect($r['logs'])->toContain("NOTE: User 'target' was $action by user 'admin'");
|
||||||
|
})->with([['enable', 'enabled'], ['disable', 'disabled'], ['lock', 'locked'], ['unlock', 'unlocked']]);
|
||||||
|
|
||||||
|
test('invalid audit inputs produce no log records', function () {
|
||||||
|
$r = cacti_test_isolated_probe(dirname(__DIR__) . '/fixtures/UserAdministrationAuditProbe.php', ['audit-invalid']);
|
||||||
|
expect($r['logs'])->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unavailable actor is identified by account ID', function () {
|
||||||
|
$r = cacti_test_isolated_probe(dirname(__DIR__) . '/fixtures/UserAdministrationAuditProbe.php', ['audit-unknown']);
|
||||||
|
expect($r['logs'])->toBe(["NOTE: User 'target' was edited by user 'user ID 404'"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CLI audit records identify the execution user', function () {
|
||||||
|
$r = cacti_test_isolated_probe(dirname(__DIR__) . '/fixtures/UserAdministrationAuditProbe.php', ['audit-cli']);
|
||||||
|
expect($r['logs'])->toHaveCount(1);
|
||||||
|
expect($r['logs'][0])->toContain("User 'target' was edited")->toContain(' (CLI)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('new accounts emit creation rather than an existing-account password-change event', function () {
|
||||||
|
$r = cacti_test_isolated_probe(dirname(__DIR__) . '/fixtures/UserAdministrationAuditProbe.php', ['new']);
|
||||||
|
expect(implode("\n", $r['logs']))->toContain('was created')->not->toContain('changed password');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('account audit fields cannot inject log lines or terminal controls', function () {
|
||||||
|
$r = cacti_test_isolated_probe(dirname(__DIR__) . '/fixtures/UserAdministrationAuditProbe.php', ['audit-controls']);
|
||||||
|
expect($r['logs'])->toBe(["NOTE: User 'target' was edited by user 'admin'"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('account removals are audited only when the existing row is confirmed deleted', function (string $scenario, int $remaining, bool $audited) {
|
||||||
|
$r = cacti_test_isolated_probe(dirname(__DIR__) . '/fixtures/UserAdministrationAuditProbe.php', [$scenario]);
|
||||||
|
expect($r['remaining'])->toBe($remaining)
|
||||||
|
->and($r['logs'])->toBe($audited ? ["NOTE: User 'target' was removed by user 'admin'"] : []);
|
||||||
|
})->with([
|
||||||
|
'deleted account' => ['delete', 0, true],
|
||||||
|
'template account' => ['delete-template', 1, false],
|
||||||
|
'guest account' => ['delete-guest', 1, false],
|
||||||
|
'failed deletion' => ['delete-failure', 1, false],
|
||||||
|
'failed confirmation read' => ['delete-read-failure', 0, false],
|
||||||
|
'missing account' => ['delete-missing', 0, false]
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('unconfirmed account saves emit no success audit events', function (string $scenario) {
|
||||||
|
$r = cacti_test_isolated_probe(dirname(__DIR__) . '/fixtures/UserAdministrationAuditProbe.php', [$scenario]);
|
||||||
|
expect($r['logs'])->toBe([]);
|
||||||
|
})->with(['failed-save', 'failed-save-id', 'save-read-failure', 'save-missing']);
|
||||||
|
|
||||||
|
test('bulk account transitions are audited only after the stored state changes', function (string $scenario, string|false $enabled, string|false $action) {
|
||||||
|
$r = cacti_test_isolated_probe(dirname(__DIR__) . '/fixtures/UserAdministrationAuditProbe.php', [$scenario]);
|
||||||
|
expect($r['enabled'])->toBe($enabled)
|
||||||
|
->and($r['logs'])->toBe($action === false ? [] : ["NOTE: User 'target' was $action by user 'admin'"]);
|
||||||
|
})->with([
|
||||||
|
['bulk-enable', 'on', 'enabled'],
|
||||||
|
['bulk-disable', '', 'disabled'],
|
||||||
|
['bulk-enable-failure', '', false],
|
||||||
|
['bulk-disable-failure', 'on', false],
|
||||||
|
['bulk-enable-read-failure', 'on', false],
|
||||||
|
['bulk-disable-read-failure', '', false],
|
||||||
|
['bulk-enable-missing', false, false],
|
||||||
|
['bulk-disable-missing', false, false],
|
||||||
|
['bulk-enable-noop', 'on', false],
|
||||||
|
['bulk-disable-noop', '', false],
|
||||||
|
]);
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Require measured statement coverage for PHP lines added by a pull request.
|
||||||
|
|
||||||
|
Copyright (C) 2004-2026 The Cacti Group. GPL-2.0-or-later.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
|
||||||
|
def added_lines(base, root):
|
||||||
|
merge_base = subprocess.check_output(
|
||||||
|
['git', 'merge-base', base, 'HEAD'], cwd=root, text=True).strip()
|
||||||
|
diff = subprocess.check_output(
|
||||||
|
['git', '-c', 'core.quotePath=false', 'diff', '--no-ext-diff',
|
||||||
|
'--no-textconv', '--unified=0', merge_base, 'HEAD', '--', '*.php',
|
||||||
|
':(exclude)tests/**', ':(exclude)include/vendor/**'], cwd=root, text=True)
|
||||||
|
changed = {}
|
||||||
|
name = None
|
||||||
|
line = 0
|
||||||
|
for record in diff.splitlines():
|
||||||
|
if record.startswith('diff --git '):
|
||||||
|
name = None
|
||||||
|
elif record.startswith('+++ b/'):
|
||||||
|
name = record[6:]
|
||||||
|
changed.setdefault(name, set())
|
||||||
|
elif record.startswith('@@'):
|
||||||
|
line = int(re.search(r'\+(\d+)', record).group(1))
|
||||||
|
elif record.startswith('+') and name is not None:
|
||||||
|
changed[name].add(line)
|
||||||
|
line += 1
|
||||||
|
elif not record.startswith(('-', '\\')):
|
||||||
|
line += 1
|
||||||
|
return {name: lines for name, lines in changed.items() if lines}
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_config(config, output, changed):
|
||||||
|
"""Keep the test suite, but include every changed production PHP file."""
|
||||||
|
tree = ET.parse(config)
|
||||||
|
source = tree.getroot().find('source')
|
||||||
|
if source is None:
|
||||||
|
source = ET.SubElement(tree.getroot(), 'source')
|
||||||
|
source.clear()
|
||||||
|
include = ET.SubElement(source, 'include')
|
||||||
|
for name in sorted(changed):
|
||||||
|
ET.SubElement(include, 'file').text = name
|
||||||
|
ET.indent(tree, space=' ')
|
||||||
|
tree.write(output, encoding='utf-8', xml_declaration=True)
|
||||||
|
|
||||||
|
|
||||||
|
def measure(changed, report, root, request_reports=None):
|
||||||
|
evidence = {}
|
||||||
|
for file in ET.parse(report).iter('file'):
|
||||||
|
try:
|
||||||
|
name = str(Path(file.attrib['name']).resolve().relative_to(root.resolve()))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
statements = evidence.setdefault(name, {})
|
||||||
|
for item in file.findall('line'):
|
||||||
|
if item.attrib.get('type') == 'stmt':
|
||||||
|
number = int(item.attrib['num'])
|
||||||
|
statements[number] = max(statements.get(number, 0), int(item.attrib['count']))
|
||||||
|
if request_reports:
|
||||||
|
reports = list(request_reports.glob('*.json'))
|
||||||
|
if not reports:
|
||||||
|
raise ValueError('Request coverage directory contains no reports')
|
||||||
|
for path in reports:
|
||||||
|
request = json.loads(path.read_text())
|
||||||
|
for file, lines in (request['coverage'] or {}).items():
|
||||||
|
name = str(Path(file).relative_to(request['root']))
|
||||||
|
if name not in changed:
|
||||||
|
continue
|
||||||
|
if hashlib.sha256((root / name).read_bytes()).hexdigest() != request['hashes'].get(file):
|
||||||
|
raise ValueError(f'Request coverage source does not match {name}')
|
||||||
|
statements = evidence.get(name, {})
|
||||||
|
for number, hits in (lines or {}).items():
|
||||||
|
number = int(number)
|
||||||
|
# The application bootstrap runs before PHPUnit starts its driver.
|
||||||
|
# Its native request instructions must still enter the denominator.
|
||||||
|
if name == 'include/global.php' and number in changed[name] and hits != -2:
|
||||||
|
statements.setdefault(number, 0)
|
||||||
|
if number in statements and hits > 0:
|
||||||
|
statements[number] = max(statements[number], hits)
|
||||||
|
for start, end in request.get('html_spans', {}).get(file, []):
|
||||||
|
if (lines or {}).get(str(end), 0) > 0:
|
||||||
|
for number in changed[name] & statements.keys():
|
||||||
|
if start <= number <= end:
|
||||||
|
statements[number] = max(statements[number], 1)
|
||||||
|
files = {}
|
||||||
|
for name, added in changed.items():
|
||||||
|
if name not in evidence:
|
||||||
|
files[name] = {'missing': True}
|
||||||
|
continue
|
||||||
|
statements = evidence[name]
|
||||||
|
if name == 'include/global.php':
|
||||||
|
source_lines = (root / name).read_text().splitlines()
|
||||||
|
for number in added:
|
||||||
|
if re.match(r'^\s*(?:\$|(?:return|throw|if|elseif|foreach|while|for|print|echo|require|require_once|include|include_once)\b|[A-Za-z_\\][\w\\]*\()', source_lines[number - 1]):
|
||||||
|
statements.setdefault(number, 0)
|
||||||
|
executable = added & statements.keys()
|
||||||
|
covered = {number for number in executable if statements[number] > 0}
|
||||||
|
files[name] = {
|
||||||
|
'covered': len(covered), 'executable': len(executable),
|
||||||
|
'uncovered': sorted(executable - covered),
|
||||||
|
}
|
||||||
|
covered = sum(item.get('covered', 0) for item in files.values())
|
||||||
|
total = sum(item.get('executable', 0) for item in files.values())
|
||||||
|
return {
|
||||||
|
'covered': covered, 'executable': total,
|
||||||
|
'percent': covered / total * 100 if total else None, 'files': files,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def passes(result, minimum):
|
||||||
|
if any(item.get('missing') for item in result['files'].values()):
|
||||||
|
return False
|
||||||
|
return result['executable'] == 0 or result['covered'] * 100 >= minimum * result['executable']
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--base', required=True)
|
||||||
|
parser.add_argument('--prepare-config', type=Path)
|
||||||
|
parser.add_argument('--output-config', type=Path)
|
||||||
|
parser.add_argument('--report', type=Path)
|
||||||
|
parser.add_argument('--request-reports', type=Path)
|
||||||
|
parser.add_argument('--minimum', type=float, default=80)
|
||||||
|
parser.add_argument('--json-output', type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not 0 <= args.minimum <= 100:
|
||||||
|
parser.error('--minimum must be between 0 and 100')
|
||||||
|
root = Path.cwd()
|
||||||
|
changed = added_lines(args.base, root)
|
||||||
|
if args.prepare_config:
|
||||||
|
if not args.output_config:
|
||||||
|
parser.error('--prepare-config requires --output-config')
|
||||||
|
prepare_config(args.prepare_config, args.output_config, changed)
|
||||||
|
print(f'Coverage scope includes {len(changed)} changed production PHP files.')
|
||||||
|
return 0
|
||||||
|
if not args.report:
|
||||||
|
parser.error('--report is required when measuring coverage')
|
||||||
|
result = measure(changed, args.report, root, args.request_reports)
|
||||||
|
output = json.dumps(result, indent=2)
|
||||||
|
print(output)
|
||||||
|
if args.json_output:
|
||||||
|
args.json_output.write_text(output + '\n')
|
||||||
|
return 0 if passes(result, args.minimum) else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
sys.exit(main())
|
||||||
|
except (OSError, ValueError, KeyError, ET.ParseError, subprocess.CalledProcessError) as error:
|
||||||
|
print(f'Changed PHP coverage could not be verified: {error}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Copyright (C) 2004-2026 The Cacti Group. GPL-2.0-or-later.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${BASE_COMMIT:?Coverage needs the pull request base commit}"
|
||||||
|
: "${CACTI_PR_COVERAGE_OUTPUT:?Coverage needs an evidence directory}"
|
||||||
|
mkdir -p "$CACTI_PR_COVERAGE_OUTPUT"
|
||||||
|
command -v grep > /dev/null
|
||||||
|
merge_base="$(git merge-base "$BASE_COMMIT" HEAD)"
|
||||||
|
changed_paths="$(git diff --name-only "$merge_base" HEAD)"
|
||||||
|
|
||||||
|
run_probe() {
|
||||||
|
local script="$1" pattern="$2" port="$3"
|
||||||
|
if [[ -f "$script" ]] && grep -Eq "$pattern" <<< "$changed_paths"; then
|
||||||
|
HOST_PORT="$port" bash "$script"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_probe tests/e2e/aggregate-item-overrides.sh '^(aggregate_(graphs|templates)\.php|lib/api_aggregate\.php|install/upgrades/1_3_0\.php|tests/e2e/aggregate-item-overrides)' 18273
|
||||||
|
CACTI_AGGREGATE_TEST_BROWSER=1 run_probe tests/e2e/aggregate-automation.sh '^(automation_aggregates\.php|lib/(api_automation|automation|api_aggregate)\.php|install/upgrades/1_3_0\.php|tests/e2e/aggregate-automation)' 18274
|
||||||
|
run_probe tests/e2e/accessibility-coverage.sh '^((about|aggregate_graphs|auth_changepassword|auth_resetpassword|automation_templates|color|data_queries|data_templates|graph_realtime|graph_xport|graphs_new|host|host_templates|index|package|permission_denied|plugins|tree|user_admin|user_group_admin|utilities)\.php|include/top_(general_|graph_)?header\.php|install/install\.php|lib/html(_form|_graph)?\.php|tests/e2e/accessibility)' 18275
|
||||||
|
run_probe tests/e2e/queue/run.sh '^(lib/api_queue\.php|cli/queue_(admin|worker)\.php|include/global_settings\.php|install/upgrades/1_3_0\.php|tests/e2e/queue/)' 18276
|
||||||
|
|
||||||
|
run_php_probe() {
|
||||||
|
local script="$1" pattern="$2"
|
||||||
|
local php_args=(-d memory_limit=2G)
|
||||||
|
if [[ -n "${CACTI_PR_XDEBUG_EXTENSION:-}" ]]; then
|
||||||
|
php_args+=(-d "zend_extension=$CACTI_PR_XDEBUG_EXTENSION")
|
||||||
|
fi
|
||||||
|
if [[ -f "$script" ]] && grep -Eq "$pattern" <<< "$changed_paths"; then
|
||||||
|
CACTI_PR_RAW_COVERAGE="$CACTI_PR_COVERAGE_OUTPUT" XDEBUG_MODE=coverage php "${php_args[@]}" "$script"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_php_probe tests/fixtures/Core/Auth/BoundaryContracts.php '^(auth_changepassword\.php|include/global\.php|lib/auth\.php|package_import\.php|tests/fixtures/Core/Auth/BoundaryContracts\.php)'
|
||||||
|
|
||||||
|
run_php_probe tests/fixtures/Core/Rrd/FallbackSourceIdentity.php '^(lib/rrd\.php|tests/fixtures/Core/Rrd/FallbackSourceIdentity\.php)'
|
||||||
|
|
||||||
|
run_php_probe tests/fixtures/UserAdministrationAuditCoverage.php '^(user_admin\.php|lib/functions\.php|tests/fixtures/UserAdministrationAudit)'
|
||||||
|
|
||||||
|
run_probe tests/e2e/installer-coverage.sh '^(lib/installer\.php|cli/(install_cacti|upgrade_database)\.php|install/(functions|preflight|report|background|install)\.php|tests/e2e/installer)' 18277
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Regression tests for missing files and coverage threshold boundaries.
|
||||||
|
|
||||||
|
Copyright (C) 2004-2026 The Cacti Group. GPL-2.0-or-later.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
sys.dont_write_bytecode = True
|
||||||
|
SPEC = importlib.util.spec_from_file_location(
|
||||||
|
'changed_php_coverage', Path(__file__).with_name('check_changed_php_coverage.py'))
|
||||||
|
GATE = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(GATE)
|
||||||
|
|
||||||
|
|
||||||
|
class ChangedPhpCoverageTest(unittest.TestCase):
|
||||||
|
def test_diff_uses_merge_base_and_ignores_deletion_only_files(self):
|
||||||
|
diff = ('diff --git a/lib/a.php b/lib/a.php\n+++ b/lib/a.php\n'
|
||||||
|
'@@ -4,0 +5,2 @@\n+return true;\n+}\n'
|
||||||
|
'diff --git a/lib/b.php b/lib/b.php\n+++ b/lib/b.php\n'
|
||||||
|
'@@ -3 +2,0 @@\n-return false;\n')
|
||||||
|
with patch.object(GATE.subprocess, 'check_output', side_effect=['merge-base\n', diff]) as command:
|
||||||
|
self.assertEqual(GATE.added_lines('base', Path('.')), {'lib/a.php': {5, 6}})
|
||||||
|
self.assertIn('merge-base', command.call_args_list[1].args[0])
|
||||||
|
|
||||||
|
def test_missing_entrypoint_fails_even_if_library_is_fully_covered(self):
|
||||||
|
result = {'covered': 100, 'executable': 100, 'files': {
|
||||||
|
'lib/a.php': {'covered': 100}, 'plugins.php': {'missing': True}}}
|
||||||
|
self.assertFalse(GATE.passes(result, 80))
|
||||||
|
|
||||||
|
def test_threshold_does_not_round_a_failure_up_to_eighty(self):
|
||||||
|
self.assertFalse(GATE.passes({'covered': 7999, 'executable': 10000, 'files': {}}, 80))
|
||||||
|
self.assertTrue(GATE.passes({'covered': 80, 'executable': 100, 'files': {}}, 80))
|
||||||
|
|
||||||
|
def test_no_executable_changes_are_not_a_missing_report(self):
|
||||||
|
self.assertTrue(GATE.passes({'covered': 0, 'executable': 0, 'files': {}}, 80))
|
||||||
|
self.assertFalse(GATE.passes({'covered': 0, 'executable': 0, 'files': {
|
||||||
|
'plugins.php': {'missing': True}}}, 80))
|
||||||
|
|
||||||
|
def test_clover_counts_only_statements_on_changed_lines(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
report = root / 'coverage.xml'
|
||||||
|
report.write_text(f'<coverage><project><file name="{root}/plugins.php">'
|
||||||
|
'<line num="3" type="method" count="1"/>'
|
||||||
|
'<line num="3" type="stmt" count="0"/>'
|
||||||
|
'<line num="4" type="stmt" count="2"/>'
|
||||||
|
'<line num="9" type="stmt" count="5"/>'
|
||||||
|
'</file></project></coverage>')
|
||||||
|
result = GATE.measure({'plugins.php': {3, 4, 5}}, report, root)
|
||||||
|
self.assertEqual(result['covered'], 1)
|
||||||
|
self.assertEqual(result['executable'], 2)
|
||||||
|
self.assertEqual(result['files']['plugins.php']['uncovered'], [3])
|
||||||
|
|
||||||
|
def test_config_keeps_tests_and_adds_entrypoints_to_source_scope(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
config = Path(directory) / 'input.xml'
|
||||||
|
output = Path(directory) / 'output.xml'
|
||||||
|
config.write_text('<phpunit bootstrap="tests/bootstrap-unit.php">'
|
||||||
|
'<testsuites><testsuite name="Unit"><directory>tests/Unit</directory>'
|
||||||
|
'</testsuite></testsuites><source><include><directory>lib</directory>'
|
||||||
|
'</include></source></phpunit>')
|
||||||
|
GATE.prepare_config(config, output, {'lib/plugins.php': {3}, 'plugins.php': {4}})
|
||||||
|
tree = ET.parse(output)
|
||||||
|
self.assertEqual(tree.getroot().attrib['bootstrap'], 'tests/bootstrap-unit.php')
|
||||||
|
self.assertEqual(tree.findtext('testsuites/testsuite/directory'), 'tests/Unit')
|
||||||
|
self.assertEqual([file.text for file in tree.findall('source/include/file')],
|
||||||
|
['lib/plugins.php', 'plugins.php'])
|
||||||
|
|
||||||
|
def test_request_reports_require_matching_source_and_keep_uncovered_lines(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
source = root / 'plugins.php'
|
||||||
|
source.write_text('<?php\nreturn true;\n')
|
||||||
|
report = root / 'coverage.xml'
|
||||||
|
report.write_text(f'<coverage><project><file name="{source}">'
|
||||||
|
'<line num="2" type="stmt" count="0"/>'
|
||||||
|
'<line num="3" type="stmt" count="0"/>'
|
||||||
|
'</file></project></coverage>')
|
||||||
|
requests = root / 'requests'
|
||||||
|
requests.mkdir()
|
||||||
|
snapshot = {'root': '/var/www/html', 'coverage': {
|
||||||
|
'/var/www/html/plugins.php': {'2': 1}}, 'hashes': {
|
||||||
|
'/var/www/html/plugins.php': hashlib.sha256(source.read_bytes()).hexdigest()}}
|
||||||
|
(requests / 'one.json').write_text(json.dumps(snapshot))
|
||||||
|
result = GATE.measure({'plugins.php': {2, 3}}, report, root, requests)
|
||||||
|
self.assertEqual(result['covered'], 1)
|
||||||
|
self.assertEqual(result['executable'], 2)
|
||||||
|
self.assertEqual(result['files']['plugins.php']['uncovered'], [3])
|
||||||
|
source.write_text('<?php\nreturn false;\n')
|
||||||
|
with self.assertRaisesRegex(ValueError, 'source does not match'):
|
||||||
|
GATE.measure({'plugins.php': {2, 3}}, report, root, requests)
|
||||||
|
|
||||||
|
def test_missing_request_reports_do_not_pass(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
report = root / 'coverage.xml'
|
||||||
|
report.write_text('<coverage/>')
|
||||||
|
with self.assertRaisesRegex(ValueError, 'contains no reports'):
|
||||||
|
GATE.measure({}, report, root, root)
|
||||||
|
|
||||||
|
def test_bootstrap_instructions_remain_in_the_coverage_denominator(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / 'include').mkdir()
|
||||||
|
source = root / 'include/global.php'
|
||||||
|
source.write_text('<?php\n$theme = "modern";\n$_SESSION["theme"] = $theme;\n')
|
||||||
|
report = root / 'coverage.xml'
|
||||||
|
report.write_text(f'<coverage><project><file name="{source}"/></project></coverage>')
|
||||||
|
result = GATE.measure({'include/global.php': {2, 3}}, report, root)
|
||||||
|
self.assertEqual(result['executable'], 2)
|
||||||
|
self.assertFalse(GATE.passes(result, 80))
|
||||||
|
requests = root / 'requests'
|
||||||
|
requests.mkdir()
|
||||||
|
snapshot = {'root': '/var/www/html', 'coverage': {
|
||||||
|
'/var/www/html/include/global.php': {'2': 1, '3': -1}}, 'hashes': {
|
||||||
|
'/var/www/html/include/global.php': hashlib.sha256(source.read_bytes()).hexdigest()}}
|
||||||
|
(requests / 'one.json').write_text(json.dumps(snapshot))
|
||||||
|
result = GATE.measure({'include/global.php': {2, 3}}, report, root, requests)
|
||||||
|
self.assertEqual(result['covered'], 1)
|
||||||
|
self.assertEqual(result['executable'], 2)
|
||||||
|
self.assertFalse(GATE.passes(result, 80))
|
||||||
|
|
||||||
|
def test_multiline_html_is_covered_only_when_its_output_opcode_runs(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
source = root / 'page.php'
|
||||||
|
source.write_text('<?php ?>\n<header>\nName\n</header><?php return;\n')
|
||||||
|
report = root / 'coverage.xml'
|
||||||
|
report.write_text(f'<coverage><project><file name="{source}">'
|
||||||
|
'<line num="2" type="stmt" count="0"/>'
|
||||||
|
'</file></project></coverage>')
|
||||||
|
requests = root / 'requests'
|
||||||
|
requests.mkdir()
|
||||||
|
snapshot = {'root': '/var/www/html', 'coverage': {
|
||||||
|
'/var/www/html/page.php': {'4': -1}}, 'hashes': {
|
||||||
|
'/var/www/html/page.php': hashlib.sha256(source.read_bytes()).hexdigest()},
|
||||||
|
'html_spans': {'/var/www/html/page.php': [[2, 4]]}}
|
||||||
|
(requests / 'one.json').write_text(json.dumps(snapshot))
|
||||||
|
self.assertEqual(GATE.measure({'page.php': {2}}, report, root, requests)['covered'], 0)
|
||||||
|
snapshot['coverage']['/var/www/html/page.php']['4'] = 1
|
||||||
|
(requests / 'one.json').write_text(json.dumps(snapshot))
|
||||||
|
self.assertEqual(GATE.measure({'page.php': {2}}, report, root, requests)['covered'], 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Verify request coverage dispatch using only the runner's required tools.
|
||||||
|
|
||||||
|
Copyright (C) 2004-2026 The Cacti Group. GPL-2.0-or-later.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class RequestDispatchTest(unittest.TestCase):
|
||||||
|
def test_queue_change_runs_the_queue_probe_without_ripgrep(self):
|
||||||
|
self.dispatch('cli/queue_worker.php', 'queue')
|
||||||
|
|
||||||
|
def test_html_change_runs_the_browser_probe(self):
|
||||||
|
self.dispatch('lib/html.php', 'accessibility')
|
||||||
|
|
||||||
|
def test_documentation_change_runs_no_request_probe(self):
|
||||||
|
self.dispatch('README.md', None)
|
||||||
|
|
||||||
|
def test_installer_change_runs_the_installer_probe(self):
|
||||||
|
self.dispatch('lib/installer.php', 'installer')
|
||||||
|
|
||||||
|
def test_graph_change_runs_the_native_graph_probe(self):
|
||||||
|
self.dispatch('lib/rrd.php', 'FallbackSourceIdentity.php')
|
||||||
|
|
||||||
|
def test_account_change_runs_the_native_audit_probe(self):
|
||||||
|
self.dispatch('lib/functions.php', 'UserAdministrationAuditCoverage.php')
|
||||||
|
|
||||||
|
def test_auth_change_runs_the_native_auth_probe(self):
|
||||||
|
self.dispatch('lib/auth.php', 'BoundaryContracts.php')
|
||||||
|
|
||||||
|
def dispatch(self, changed, expected):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
repository = root / 'repo'
|
||||||
|
repository.mkdir()
|
||||||
|
tools = root / 'tools'
|
||||||
|
tools.mkdir()
|
||||||
|
for command in ['git', 'bash', 'grep', 'mkdir']:
|
||||||
|
(tools / command).symlink_to(shutil.which(command))
|
||||||
|
php = tools / 'php'
|
||||||
|
php.write_text('#!' + shutil.which('bash') + '\n'
|
||||||
|
'script="${!#}"\n'
|
||||||
|
'printf "%s\\n" "${script##*/}" >> "$CACTI_PR_RAW_COVERAGE/probes"\n')
|
||||||
|
php.chmod(0o700)
|
||||||
|
env = dict(os.environ, PATH=str(tools))
|
||||||
|
|
||||||
|
def git(*arguments):
|
||||||
|
return subprocess.check_output(
|
||||||
|
['git', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null',
|
||||||
|
'-c', 'user.name=Coverage fixture', '-c', 'user.email=fixture@example.invalid',
|
||||||
|
*arguments], cwd=repository, env=env, text=True).strip()
|
||||||
|
|
||||||
|
git('init', '--quiet')
|
||||||
|
(repository / 'README.md').write_text('fixture\n')
|
||||||
|
for name, script in [('queue', 'queue/run.sh'), ('accessibility', 'accessibility-coverage.sh'),
|
||||||
|
('installer', 'installer-coverage.sh')]:
|
||||||
|
path = repository / 'tests/e2e' / script
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text('printf "%s\\n" ' + name + ' >> "$CACTI_PR_COVERAGE_OUTPUT/probes"\n')
|
||||||
|
for script in ['Core/Auth/BoundaryContracts.php', 'Core/Rrd/FallbackSourceIdentity.php',
|
||||||
|
'UserAdministrationAuditCoverage.php']:
|
||||||
|
path = repository / 'tests/fixtures' / script
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text('<?php // dispatch fixture\n')
|
||||||
|
git('add', '.')
|
||||||
|
git('commit', '--quiet', '-m', 'fixture base')
|
||||||
|
base = git('rev-parse', 'HEAD')
|
||||||
|
path = repository / changed
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text('changed fixture\n')
|
||||||
|
git('add', '.')
|
||||||
|
git('commit', '--quiet', '-m', 'fixture change')
|
||||||
|
output = root / 'coverage'
|
||||||
|
runner = Path(__file__).with_name('run_request_coverage.sh').resolve()
|
||||||
|
subprocess.run(['bash', str(runner)], cwd=repository, env=dict(
|
||||||
|
env, BASE_COMMIT=base, CACTI_PR_COVERAGE_OUTPUT=str(output)), check=True)
|
||||||
|
probe = output / 'probes'
|
||||||
|
self.assertEqual(probe.read_text().splitlines() if probe.exists() else [],
|
||||||
|
[expected] if expected else [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
+60
-3
@@ -277,19 +277,38 @@ function form_actions() : void {
|
|||||||
if (gnrv('drp_action') == '1') { // delete
|
if (gnrv('drp_action') == '1') { // delete
|
||||||
for ($i = 0; ($i < cacti_count($selected_items)); $i++) {
|
for ($i = 0; ($i < cacti_count($selected_items)); $i++) {
|
||||||
if ($_SESSION[SESS_USER_ID] != $selected_items[$i]) {
|
if ($_SESSION[SESS_USER_ID] != $selected_items[$i]) {
|
||||||
|
$affected_user = get_username($selected_items[$i]);
|
||||||
user_remove($selected_items[$i]);
|
user_remove($selected_items[$i]);
|
||||||
|
|
||||||
|
if (is_string($affected_user) && $affected_user !== '' && db_fetch_row_prepared('SELECT id FROM user_auth WHERE id = ?', [$selected_items[$i]]) === []) {
|
||||||
|
log_user_action($affected_user, 'removed');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
raise_message('attempt current', __('You are not allowed to delete the current login account'), MESSAGE_LEVEL_ERROR);
|
raise_message('attempt current', __('You are not allowed to delete the current login account'), MESSAGE_LEVEL_ERROR);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} elseif (gnrv('drp_action') == '3') { // enable
|
} elseif (gnrv('drp_action') == '3') { // enable
|
||||||
for ($i = 0; ($i < cacti_count($selected_items)); $i++) {
|
for ($i = 0; ($i < cacti_count($selected_items)); $i++) {
|
||||||
|
$original_user = db_fetch_row_prepared('SELECT username, enabled FROM user_auth WHERE id = ?', [$selected_items[$i]]);
|
||||||
user_enable($selected_items[$i]);
|
user_enable($selected_items[$i]);
|
||||||
|
$saved_user = db_fetch_row_prepared('SELECT username, enabled FROM user_auth WHERE id = ?', [$selected_items[$i]]);
|
||||||
|
|
||||||
|
if (is_array($original_user) && $original_user !== [] && is_array($saved_user) && $saved_user !== [] &&
|
||||||
|
($original_user['enabled'] ?? '') !== 'on' && ($saved_user['enabled'] ?? '') === 'on') {
|
||||||
|
log_user_action($saved_user['username'], 'enabled');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} elseif (gnrv('drp_action') == '4') { // disable
|
} elseif (gnrv('drp_action') == '4') { // disable
|
||||||
for ($i = 0; ($i < cacti_count($selected_items)); $i++) {
|
for ($i = 0; ($i < cacti_count($selected_items)); $i++) {
|
||||||
if ($_SESSION[SESS_USER_ID] != $selected_items[$i]) {
|
if ($_SESSION[SESS_USER_ID] != $selected_items[$i]) {
|
||||||
|
$original_user = db_fetch_row_prepared('SELECT username, enabled FROM user_auth WHERE id = ?', [$selected_items[$i]]);
|
||||||
user_disable($selected_items[$i]);
|
user_disable($selected_items[$i]);
|
||||||
|
$saved_user = db_fetch_row_prepared('SELECT username, enabled FROM user_auth WHERE id = ?', [$selected_items[$i]]);
|
||||||
|
|
||||||
|
if (is_array($original_user) && $original_user !== [] && is_array($saved_user) && $saved_user !== [] &&
|
||||||
|
($original_user['enabled'] ?? '') === 'on' && ($saved_user['enabled'] ?? '') === '') {
|
||||||
|
log_user_action($saved_user['username'], 'disabled');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
raise_message('attempt current', __('You are not allowed to disable the current login account'), MESSAGE_LEVEL_ERROR);
|
raise_message('attempt current', __('You are not allowed to disable the current login account'), MESSAGE_LEVEL_ERROR);
|
||||||
}
|
}
|
||||||
@@ -513,6 +532,11 @@ function form_save() : void {
|
|||||||
gfrv('policy_graph_templates');
|
gfrv('policy_graph_templates');
|
||||||
// ====================================================
|
// ====================================================
|
||||||
|
|
||||||
|
$original_user = db_fetch_row_prepared('SELECT username, enabled, locked
|
||||||
|
FROM user_auth
|
||||||
|
WHERE id = ?',
|
||||||
|
[get_nfilter_request_var('id')]);
|
||||||
|
|
||||||
$old_password = db_fetch_cell_prepared('SELECT password
|
$old_password = db_fetch_cell_prepared('SELECT password
|
||||||
FROM user_auth
|
FROM user_auth
|
||||||
WHERE id = ?',
|
WHERE id = ?',
|
||||||
@@ -595,12 +619,45 @@ function form_save() : void {
|
|||||||
db_execute_prepared('DELETE FROM sessions WHERE user_id = ?', [$save['id']]);
|
db_execute_prepared('DELETE FROM sessions WHERE user_id = ?', [$save['id']]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$is_new_user = empty($save['id']);
|
||||||
|
|
||||||
$save = api_plugin_hook_function('user_admin_setup_sql_save', $save);
|
$save = api_plugin_hook_function('user_admin_setup_sql_save', $save);
|
||||||
|
|
||||||
if (!is_error_message()) {
|
if (!is_error_message()) {
|
||||||
$user_id = sql_save($save, 'user_auth');
|
$user_id = sql_save($save, 'user_auth');
|
||||||
|
|
||||||
if ($user_id) {
|
if ($user_id) {
|
||||||
|
$saved_user = db_fetch_row_prepared('SELECT id, username, password, enabled, locked FROM user_auth WHERE id = ?', [$user_id]);
|
||||||
|
|
||||||
|
// sql_save() success and the intended audit fields must both be
|
||||||
|
// confirmed before recording a completed account action.
|
||||||
|
if (!is_array($saved_user) ||
|
||||||
|
($saved_user['username'] ?? null) !== $save['username'] ||
|
||||||
|
($saved_user['password'] ?? null) !== $save['password'] ||
|
||||||
|
($saved_user['enabled'] ?? null) !== $save['enabled'] ||
|
||||||
|
($saved_user['locked'] ?? null) !== $save['locked']) {
|
||||||
|
$saved_user = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($saved_user !== []) {
|
||||||
|
log_user_action($saved_user['username'], $is_new_user ? 'created' : 'edited');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$is_new_user && is_array($original_user) && $original_user !== [] && $saved_user !== []) {
|
||||||
|
$was_enabled = ($original_user['enabled'] ?? '') === 'on';
|
||||||
|
$is_enabled = ($saved_user['enabled'] ?? '') === 'on';
|
||||||
|
$was_locked = ($original_user['locked'] ?? '') === 'on';
|
||||||
|
$is_locked = ($saved_user['locked'] ?? '') === 'on';
|
||||||
|
|
||||||
|
if ($was_enabled !== $is_enabled) {
|
||||||
|
log_user_action($saved_user['username'], $is_enabled ? 'enabled' : 'disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($was_locked !== $is_locked) {
|
||||||
|
log_user_action($saved_user['username'], $is_locked ? 'locked' : 'unlocked');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$reset_link_created = false;
|
$reset_link_created = false;
|
||||||
|
|
||||||
if (($save['id'] == 0 && read_config_option('secnotify_newuser') == 'on') ||
|
if (($save['id'] == 0 && read_config_option('secnotify_newuser') == 'on') ||
|
||||||
@@ -630,8 +687,6 @@ function form_save() : void {
|
|||||||
|
|
||||||
send_mail($save['email_address'], null, read_config_option('secnotify_newuser_subject'), $body, [], [], true);
|
send_mail($save['email_address'], null, read_config_option('secnotify_newuser_subject'), $body, [], [], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
cacti_log(sprintf('NOTE: New user created, username %s, created by %s', $save['email_address'], get_username()), false, 'SYSTEM');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($save['id'] > 0) {
|
if ($save['id'] > 0) {
|
||||||
@@ -641,7 +696,9 @@ function form_save() : void {
|
|||||||
send_mail($save['email_address'], null, read_config_option('secnotify_chpass_subject'), $body, [], [], true);
|
send_mail($save['email_address'], null, read_config_option('secnotify_chpass_subject'), $body, [], [], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
cacti_log(sprintf('NOTE: Admin %s, changed password for user %s', get_username(), $save['email_address']), false, 'SYSTEM');
|
if (is_array($original_user) && $original_user !== [] && $saved_user !== [] && $saved_user['password'] !== $old_password) {
|
||||||
|
log_user_action($saved_user['username'], 'changed password');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
raise_message(1);
|
raise_message(1);
|
||||||
|
|||||||
Reference in new issue
Block a user