diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index cfe9f521..d2017c1d 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -220,6 +220,8 @@ jobs: php tests/regression/threshold_recovery_database_test.php php tests/regression/quality_must_fix_database_test.php php tests/regression/incoming_preservation_database_test.php + php tests/regression/review_fixes_database_test.php + php tests/regression/review_fixes_paths_database_test.php working-directory: ${{ github.workspace }}/cacti/plugins/syslog env: SYSLOG_TEST_MYSQL_DSN: 'mysql:host=127.0.0.1;port=3306' @@ -311,6 +313,25 @@ jobs: cat log/cacti.log exit 1 fi + # The populated alert rule (id 1) must have been evaluated and have fired + # for the host "bob": the processor logs it and stores it in syslog_logs. + if ! grep -q "The Syslog Alert 'interface down alert' with Severity 'Warning', has been Triggered on Host 'bob'" log/cacti.log; then + echo "The populated alert rule did not fire" + cat log/cacti.log + exit 1 + fi + alerts=$(mysql --defaults-file="$HOME/.my.cnf" cacti -N -e "SELECT COUNT(*) FROM syslog_logs WHERE alert_id = 1 AND host = 'bob'") + if [ "$alerts" -lt 1 ]; then + echo "The populated alert rule left no alert log row" + exit 1 + fi + + # Runs the real processor over queued messages and reads the results back. + # The script removes its rows and restores cacti.log to its starting size. + - name: Run Syslog live pipeline checks + run: | + cd ${{ github.workspace }}/cacti + sudo -u www-data env SYSLOG_TEST_LIVE=1 php plugins/syslog/tests/regression/live_pipeline_test.php - name: View Cacti Logs if: always() @@ -324,14 +345,17 @@ jobs: if: always() run: | if [ -f ${{ github.workspace }}/cacti/log/cacti.log ]; then - if sudo grep -E "FATAL:|ERROR:|WARNING:" "${{ github.workspace }}/cacti/log/cacti.log"; then + status=0 + matches=$(sudo grep -E "FATAL:|ERROR:|WARNING:" "${{ github.workspace }}/cacti/log/cacti.log") || status=$? + if [ "$status" -gt 1 ]; then + echo "Unable to inspect Cacti log" + exit "$status" + fi + # The only entry allowed is the alert the populated rule is meant to raise. + unexpected=$(printf '%s\n' "$matches" | grep -v -F "The Syslog Alert 'interface down alert' with Severity 'Warning', has been Triggered on Host 'bob'" | grep -v '^$') || true + if [ -n "$unexpected" ]; then + printf '%s\n' "$unexpected" echo "Found FATAL/ERROR/WARNING entries in Cacti log" exit 1 - else - status=$? - if [ "$status" -ne 1 ]; then - echo "Unable to inspect Cacti log" - exit "$status" - fi fi fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ec352d5..166cb735 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,50 @@ --- develop --- +* test: Extend the browser E2E run with import, reprocess, dashboard and long owner name paths, and make its login work on Cacti 1.2.x and develop + +* test: Add an optional Xdebug dump and merge report that list which changed lines the real entry points executed + +* test: Run the real processor over alert command, removal rule and invalid alert rule cases against an installed Cacti in CI + +* test: Add real-database checks for each upgrade choice, every dashboard record type and dimension, every removal rule type and method, alert evaluation with invalid rules, owner column limits, hostname lookup budget and rule imports + +* bug: Reject imported alert rules whose match type produces no query + +* test: Extend the browser E2E run with dashboard, archiving removal rule, alert rule isolation, import validation and single-rule reprocess checks + +* test: Load functions extracted from plugin pages through temporary files instead of evaluating source strings + +* test: Add database regression checks for the dashboard queries, archiving removal rules, rule reprocessing, alert rule isolation and upgrade truncate scope + +* bug: Validate imported saved search templates like edited ones + +* bug: Stop hostname lookups after a time budget per processing run so senders cannot stall the poller + +* bug: Widen rule owner columns to the 50 character Cacti username limit + +* bug: Reprocess only the selected removal rule instead of every enabled rule + +* bug: Archive host and program removal rules without an ambiguous column error + +* bug: Group the All records dashboard queries per log table so panels show every host, program and time bucket + +* bug: Validate imported alert rules like edited ones and ignore row ids in import files + +* bug: Apply the Max Report Records setting to threshold alert messages + +* bug: Evaluate the remaining alert rules when one rule fails to compile instead of silencing all of them + +* bug: Keep asterisks inside a host-level alert rule expression intact when the grouped query is built + +* bug: Keep alert, removal and report rules when the upgrade type Truncate is chosen; it now discards stored messages only + +* security: Substitute alert command tokens in a single pass so values containing a token name are not expanded again + +* test: Insert a valid alert rule in the CI test data and assert in the workflow that the processor evaluates and fires it + +* bug: Check sender names with A and AAAA records, accept IPv4 and IPv6 addresses without a lookup and store IPv6 addresses in one canonical form + * issue#317: Expire partitions by their actual UTC upper boundaries instead of partition count, preserving cutoff-spanning partitions and supporting legacy TO_DAYS layouts. * issue#317: Defer automatic partition reorganization when overflow estimates exceed 100,000 rows or 128 MiB, or cannot be read; preserve the safety partition and report the reason through maintenance health. diff --git a/README.md b/README.md index a0bfbe1d..3a7c607f 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,13 @@ If you are upgrading to version 2.5 from an earlier version, make sure that you update this template format and restart rsyslog. You may loose some syslog data, but doing this in a timely fashion, will minimize data loss. +Senders that only have an IPv6 address are supported. With hostname resolution +enabled the plugin accepts an IPv4 or IPv6 address as it is, and stores an IPv6 +address in its compressed lower case form (for example `2001:db8::1`) so one +address is kept under one device name. Names are looked up with A and AAAA +records. The `host` columns hold 64 characters, which is enough for any IPv6 +address. + Ensure you restart rsyslog after these changes are completed. Other logging servers such as Syslog-NG are also supported with this plugin. Please see some additional documentation here: [Cacti Documentation diff --git a/includes/functions.php b/includes/functions.php index bbbba4ce..386200ed 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -1009,8 +1009,9 @@ function syslog_sendemail(string $to, array $from, string $subject, string $mess } } -const SYSLOG_IMPORT_MAX_BYTES = 5 * 1024 * 1024; -const SYSLOG_IMPORT_VERSION = 1; +const SYSLOG_IMPORT_MAX_BYTES = 5 * 1024 * 1024; +const SYSLOG_IMPORT_VERSION = 1; +const SYSLOG_RESOLVE_BUDGET_SECONDS = 60; /** * Get import payload from text input or uploaded file. @@ -3646,17 +3647,24 @@ function syslog_filter_rule_summary($json) { * * @param string $table The table to process ('syslog' or 'syslog_incoming'). * @param int $max_seq The highest incoming sequence number to consider. + * @param int $rule_id Limit processing to this removal rule, or 0 for every enabled rule. * * @return array{removed: int, xferred: int} Counts of removed and transferred records. */ -function syslog_remove_items($table, $max_seq) { +function syslog_remove_items($table, $max_seq, $rule_id = 0) { global $config, $syslog_cnn, $syslog_incoming_config; global $syslogdb_default; syslog_debug('-------------------------------------------------------------------------------------'); syslog_debug('Processing Removal Rules...'); - if ($table == 'syslog') { + if ($rule_id > 0) { + $rows = syslog_db_fetch_assoc_prepared("SELECT * + FROM `$syslogdb_default`.`syslog_remove` + WHERE enabled = 'on' + AND id = ?", + [$rule_id]); + } elseif ($table == 'syslog') { $rows = syslog_db_fetch_assoc("SELECT * FROM `$syslogdb_default`.`syslog_remove` WHERE enabled = 'on'"); @@ -3868,14 +3876,17 @@ function syslog_remove_items($table, $max_seq) { INNER JOIN `$syslogdb_default`.`syslog_programs` AS sp ON sp.program = si.program $insert_where", $insert_params); } else { + // The shared WHERE names host and program unqualified, which + // the joined reference tables would make ambiguous, so it + // filters the staging rows before the join. $move_failed = !syslog_db_execute_prepared("INSERT INTO `$syslogdb_default`.`syslog_removed` (logtime, priority_id, facility_id, program_id, host_id, message, message_digest, threshold_breached, replication_source_poller_id, replication_source_event_id) SELECT si.logtime, si.priority_id, si.facility_id, sp.program_id, sh.host_id, si.message, MD5(si.message), si.threshold_breached, {$config['poller_id']}, si.seq - FROM `$syslogdb_default`.`syslog_incoming` AS si + FROM (SELECT * FROM `$syslogdb_default`.`syslog_incoming` $sql_where) AS si INNER JOIN `$syslogdb_default`.`syslog_hosts` AS sh ON sh.host = si.host INNER JOIN `$syslogdb_default`.`syslog_programs` AS sp - ON sp.program = si.program $sql_where", $params); + ON sp.program = si.program", $params); } } else { $move_failed = !syslog_db_execute_prepared("INSERT INTO `$syslogdb_default`.`syslog_removed` @@ -5293,6 +5304,26 @@ function syslog_process_alerts($max_seq) { $rows = syslog_db_fetch_assoc_prepared($count_sql, $all_params); $row = isset($rows[0]) ? $rows[0] : []; + if ($rows === false) { + // One rule that does not compile must not silence the others. + foreach ($compiled as $index => $rule) { + if ($rule === null) { + continue; + } + + $single = syslog_db_fetch_cell_prepared("SELECT COUNT(*) + FROM `$syslogdb_default`.`syslog_incoming` + WHERE ({$rule['cond']}{$rule['device_sql']}) + AND `status` = 1 + AND `seq` <= ?", + array_merge($rule['cond_params'], $rule['device_params'], [$max_seq])); + + if ($single !== false) { + $row["m$index"] = $single; + } + } + } + foreach ($compiled as $index => $rule) { if ($rule === null) { continue; @@ -5350,7 +5381,7 @@ function syslog_process_alerts($max_seq) { if ($sql != '') { if ($alert['level'] == '1') { - $th_sql = str_replace('*', 'host, COUNT(*) AS count', $sql); + $th_sql = preg_replace('/^SELECT \*/', 'SELECT host, COUNT(*) AS count', $sql, 1); $results = syslog_db_fetch_assoc_prepared($th_sql . $groupBy, $params); if (cacti_sizeof($results)) { @@ -5373,7 +5404,7 @@ function syslog_process_alerts($max_seq) { // the consolidated Phase 1 count is the same number the // per-rule COUNT(*) scan would return; keep it instead // of scanning the staging table a second time - $count = isset($counts[$index]) ? (int) $counts[$index] : (int) syslog_db_fetch_cell_prepared(str_replace('*', 'COUNT(*)', $sql) . $groupBy, $params); + $count = isset($counts[$index]) ? (int) $counts[$index] : (int) syslog_db_fetch_cell_prepared(preg_replace('/^SELECT \*/', 'SELECT COUNT(*)', $sql, 1) . $groupBy, $params); // Windowed thresholds need every cycle's count recorded, // and their state machine decides what may fire, so // evaluation must run even below the per-cycle threshold. @@ -5491,7 +5522,8 @@ function syslog_process_alert($alert, $sql, $params, $count, $hostname = '', $at $alert_count = 0; $syslog_alarms = 0; $hostlist = []; - $max_alerts = read_config_option('syslog_maxrecords'); + $max_alerts = (int) read_config_option('syslog_maxrecords'); + $rows_shown = 0; $report_tag = false; $theme = false; $format_ok = false; @@ -5683,7 +5715,9 @@ function syslog_process_alert($alert, $sql, $params, $count, $hostname = '', $at $results['priority_id'] = $a['priority_id']; $results['facility_id'] = $a['facility_id']; - if (($alert['method'] == 1 && $alert_count < $max_alerts) || $alert['method'] == 0) { + if (($alert['method'] == 1 && ($max_alerts < 1 || $rows_shown < $max_alerts)) || $alert['method'] == 0) { + $rows_shown++; + if ($alert['method'] == 0) { $message = $hmessage; } @@ -6087,6 +6121,95 @@ function syslog_strip_incoming_domains($max_seq) { } } +/** + * Return the canonical text of an IPv4 or IPv6 address, or false when the + * sender name is not an address literal. + * + * IPv6 text is normalized (lower case, compressed, no brackets) so that one + * address arriving in several spellings is stored under one host name. A zone + * suffix such as %eth0 is kept as sent. + * + * @param string $name The host name as sent by the remote system + * + * @return string|false The canonical address text, or false for a name + */ +function syslog_host_address(string $name): string|false { + if (preg_match('/^\[?([^\]%]+)(%[A-Za-z0-9_.-]+)?\]?$/', trim($name), $match) !== 1 || !filter_var($match[1], FILTER_VALIDATE_IP)) { + return false; + } + + $zone = $match[2] ?? ''; + + // Only an IPv6 address can carry a zone. + if ($zone !== '' && strpos($match[1], ':') === false) { + return false; + } + + return inet_ntop(inet_pton($match[1])) . $zone; +} + +/** + * Whether a host name has an address record. Looks for A and AAAA records so a + * sender that only has an IPv6 address counts, and falls back to the system + * resolver, which also reads the hosts file. + * + * @param string $name The host name to look up + * @param callable $records Returns the A and AAAA records for a name, like dns_get_record() + * @param callable $legacy Returns the IPv4 address for a name, or the name itself, like gethostbyname() + * + * @return bool True when the name resolves + */ +function syslog_host_has_address(string $name, ?callable $records = null, ?callable $legacy = null): bool { + $records ??= 'dns_get_record'; + $legacy ??= 'gethostbyname'; + + // A failed query is an answer here, not a PHP warning for the Cacti log. + set_error_handler(static fn() => true); + + try { + $found = $records($name, DNS_A | DNS_AAAA); + } finally { + restore_error_handler(); + } + + if (is_array($found) && cacti_sizeof($found)) { + return true; + } + + return $name != $legacy($name); +} + +/** + * Point the unprocessed messages of one sender name at another name. + * + * @param string $from The name currently stored + * @param string $to The name to store + * @param int $max_seq The max_seq for syslog_incoming messages to process + * @param int $seq_start Optional first seq of a slice (0 for no bound) + * @param int $seq_end Optional last seq of a slice (0 for no bound) + * + * @return void + */ +function syslog_set_incoming_host(string $from, string $to, $max_seq, $seq_start = 0, $seq_end = 0): void { + global $syslogdb_default; + + if ($seq_start > 0 && $seq_end > 0) { + syslog_db_execute_prepared("UPDATE `$syslogdb_default`.`syslog_incoming` + SET host = ? + WHERE host = ? + AND `status` = 1 + AND `seq` BETWEEN ? AND ?", + [$to, $from, $seq_start, $seq_end]); + } else { + syslog_db_execute_prepared("UPDATE `$syslogdb_default`.`syslog_incoming` + SET host = ? + WHERE host = ? + AND `status` = 1 + AND `seq` <= ?", + [$to, $from, $max_seq]); + } +} + /** * Check if the hostname is in the cacti hosts table * Some devices only send IP addresses in syslog messages, and may not be in the DNS @@ -6282,10 +6405,13 @@ function syslog_update_reference_tables($max_seq) { * @param int $max_seq The max_seq for syslog_incoming messages to process * @param int $seq_start Optional first seq of a slice (0 for no bound) * @param int $seq_end Optional last seq of a slice (0 for no bound) + * @param int $budget_seconds Seconds after which DNS lookups stop for this call; + * hosts not found in Cacti are then left as received + * @param callable|null $dns_records Replacement for dns_get_record(), used by tests * * @return int The number of distinct hosts resolved */ -function syslog_resolve_incoming_hosts($max_seq, $seq_start = 0, $seq_end = 0) { +function syslog_resolve_incoming_hosts($max_seq, $seq_start = 0, $seq_end = 0, $budget_seconds = SYSLOG_RESOLVE_BUDGET_SECONDS, ?callable $dns_records = null) { global $syslogdb_default; if (read_config_option('syslog_resolve_hostname') != 'on') { @@ -6307,19 +6433,46 @@ function syslog_resolve_incoming_hosts($max_seq, $seq_start = 0, $seq_end = 0) { } $resolved_count = 0; + $deadline = microtime(true) + $budget_seconds; + $dns_skipped = false; foreach ($hosts as $host) { if (!isset($host['host']) || empty($host['host'])) { continue; } - $resolved = false; + $resolved = false; + $dns_asked = false; + $address = syslog_host_address($host['host']); + + if ($address !== false) { + // An address needs no lookup. A device in Cacti may still give it a + // better name, matched as sent first and then in canonical form. + if (!syslog_check_cacti_hosts($host['host'], $max_seq, $seq_start, $seq_end)) { + if ($address !== $host['host']) { + syslog_set_incoming_host($host['host'], $address, $max_seq, $seq_start, $seq_end); + } + + syslog_check_cacti_hosts($address, $max_seq, $seq_start, $seq_end); + } + + $resolved_count++; + + continue; + } - // Check if hostname resolves via DNS (only if DNS is enabled) + // Check if hostname resolves via DNS (only if DNS is enabled). Senders + // choose these names, so lookups stop once the time budget is spent. if (read_config_option('syslog_no_dns') != 'on') { - if ($host['host'] != gethostbyname($host['host'])) { - // DNS resolved successfully - $resolved = true; + if (microtime(true) >= $deadline) { + $dns_skipped = true; + } else { + $dns_asked = true; + + if (syslog_host_has_address($host['host'], $dns_records)) { + // DNS resolved successfully + $resolved = true; + } } } @@ -6328,6 +6481,11 @@ function syslog_resolve_incoming_hosts($max_seq, $seq_start = 0, $seq_end = 0) { $resolved = syslog_check_cacti_hosts($host['host'], $max_seq, $seq_start, $seq_end); } + // Without a DNS answer the name cannot be called unresolved + if (!$resolved && !$dns_asked && read_config_option('syslog_no_dns') != 'on') { + continue; + } + // If not resolved via DNS or found in Cacti, prefix the hostname if (!$resolved) { $unresolved_host = 'unresolved-' . $host['host']; @@ -6353,6 +6511,10 @@ function syslog_resolve_incoming_hosts($max_seq, $seq_start = 0, $seq_end = 0) { } } + if ($dns_skipped) { + cacti_log('SYSLOG WARNING: Hostname resolution budget of ' . $budget_seconds . ' seconds was spent; remaining hostnames were left as received', false, 'SYSLOG'); + } + return $resolved_count; } @@ -7671,16 +7833,16 @@ function alert_setup_environment(&$alert, $results, $hostlist = [], $hostname = function alert_replace_variables($alert, $results, $hostname = '') { global $severities, $syslog_levels, $syslog_facilities; - $command = (string) $alert['command']; - - $command = str_replace('', cacti_escapeshellarg($alert['id']), $command); - $command = str_replace('', cacti_escapeshellarg($hostname), $command); - $command = str_replace('', cacti_escapeshellarg($syslog_levels[$results['priority_id']]), $command); - $command = str_replace('', cacti_escapeshellarg($syslog_facilities[$results['facility_id']]), $command); - $command = str_replace('', cacti_escapeshellarg($results['message']), $command); - $command = str_replace('', cacti_escapeshellarg($severities[$alert['severity']]), $command); - - return $command; + // One pass: a substituted value that contains another token must not be + // expanded again, or the later value lands inside the earlier quotes. + return strtr((string) $alert['command'], [ + '' => cacti_escapeshellarg($alert['id']), + '' => cacti_escapeshellarg($hostname), + '' => cacti_escapeshellarg($syslog_levels[$results['priority_id']]), + '' => cacti_escapeshellarg($syslog_facilities[$results['facility_id']]), + '' => cacti_escapeshellarg($results['message']), + '' => cacti_escapeshellarg($severities[$alert['severity']]) + ]); } /** diff --git a/includes/installer.php b/includes/installer.php index d381b756..3fecfead 100644 --- a/includes/installer.php +++ b/includes/installer.php @@ -45,7 +45,7 @@ function syslog_install_advisor(int $syslog_exists, array $submitted_options = [ 'upgrade_type' => [ 'method' => 'drop_array', 'friendly_name' => __('What upgrade/install type do you wish to use', 'syslog'), - 'description' => __('When you have very large tables, performing a Truncate will be much quicker. If you are concerned about archive data, you can choose either Inline, which will freeze your browser for the period of this upgrade, or background, which will create a background process to bring your old syslog data from a backup table to the new syslog format. Again this process can take several hours.', 'syslog'), + 'description' => __('When you have very large tables, performing a Truncate will be much quicker. If you are concerned about archive data, you can choose either Inline, which will freeze your browser for the period of this upgrade, or background, which will create a background process to bring your old syslog data from a backup table to the new syslog format. Again this process can take several hours. A Truncate discards the stored syslog and removed messages only; alert, removal and report rules are always kept.', 'syslog'), 'value' => 'truncate', 'array' => [ 'truncate' => __('Truncate Syslog Table', 'syslog'), diff --git a/includes/schema.php b/includes/schema.php index a415f6a6..f05fa52a 100644 --- a/includes/schema.php +++ b/includes/schema.php @@ -142,7 +142,7 @@ function syslog_create_device_rule_table(): void { `pass_through_priority` int(10) NOT NULL DEFAULT '-1', `allow_maintenance` char(2) NOT NULL DEFAULT '', `notes` varchar(255) NOT NULL DEFAULT '', - `user` varchar(32) NOT NULL DEFAULT '', + `user` varchar(50) NOT NULL DEFAULT '', `date` int(10) unsigned NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `host` (`host`), @@ -164,7 +164,7 @@ function syslog_ensure_saved_search_tables(): void { ['name' => 'search', 'type' => 'text', 'NULL' => false], ['name' => 'removal', 'type' => 'int(10)', 'NULL' => false, 'default' => '1'], ['name' => 'grouping', 'type' => 'int(10)', 'NULL' => false, 'default' => '0'], - ['name' => 'user', 'type' => 'varchar(32)', 'NULL' => false, 'default' => ''], + ['name' => 'user', 'type' => 'varchar(50)', 'NULL' => false, 'default' => ''], ['name' => 'is_global', 'type' => 'char(2)', 'NULL' => false, 'default' => ''], ['name' => 'date', 'type' => 'int(16)', 'NULL' => false, 'default' => '0'] ], @@ -200,7 +200,7 @@ function syslog_ensure_dashboard_tables(): void { ['name' => 'id', 'type' => 'int(10)', 'NULL' => false, 'auto_increment' => true], ['name' => 'hash', 'type' => 'varchar(32)', 'NULL' => false, 'default' => ''], ['name' => 'name', 'type' => 'varchar(128)', 'NULL' => false, 'default' => ''], - ['name' => 'user', 'type' => 'varchar(32)', 'NULL' => false, 'default' => ''], + ['name' => 'user', 'type' => 'varchar(50)', 'NULL' => false, 'default' => ''], ['name' => 'is_global', 'type' => 'char(2)', 'NULL' => false, 'default' => ''], ['name' => 'date', 'type' => 'int(16)', 'NULL' => false, 'default' => '0'], ['name' => 'updated', 'type' => 'int(16)', 'NULL' => false, 'default' => '0'] @@ -545,10 +545,6 @@ function syslog_setup_table_new(array $options, bool $repair = false): void { // matching ROW_FORMAT for the chosen engine. syslog_create_partitioned_syslog_table($engine, $options['days'], read_config_option('syslog_partition_ahead_days')); - if ($truncate) { - syslog_db_execute("DROP TABLE IF EXISTS `$syslogdb_default`.`syslog_alert`"); - } - syslog_db_execute("CREATE TABLE IF NOT EXISTS `$syslogdb_default`.`syslog_alert` ( `id` int(10) NOT NULL auto_increment, `hash` varchar(32) NOT NULL default '', @@ -576,7 +572,7 @@ function syslog_setup_table_new(array $options, bool $repair = false): void { `open_ticket` CHAR(2) default '', `message` TEXT NOT NULL, `body` VARCHAR(8192) NOT NULL default '', - `user` varchar(32) NOT NULL default '', + `user` varchar(50) NOT NULL default '', `date` int(16) NOT NULL default '0', `email` varchar(255) default NULL, `notify` int(10) unsigned NOT NULL default '0', @@ -622,10 +618,6 @@ function syslog_setup_table_new(array $options, bool $repair = false): void { INDEX `last_sent` (`last_sent`)) ENGINE=InnoDB"); - if ($truncate) { - syslog_db_execute("DROP TABLE IF EXISTS `$syslogdb_default`.`syslog_remove`"); - } - if (stripos($engine, 'aria') !== false) { $row_format = 'ROW_FORMAT=Page'; } else { @@ -640,17 +632,13 @@ function syslog_setup_table_new(array $options, bool $repair = false): void { enabled CHAR(2) DEFAULT 'on', method CHAR(5) DEFAULT 'del', message TEXT NOT NULL, - `user` varchar(32) NOT NULL default '', + `user` varchar(50) NOT NULL default '', `date` int(16) NOT NULL default '0', notes varchar(255) default NULL, PRIMARY KEY (id)) ENGINE=$engine $row_format"); - if ($truncate) { - syslog_db_execute("DROP TABLE IF EXISTS `$syslogdb_default`.`syslog_reports`"); - } - syslog_db_execute("CREATE TABLE IF NOT EXISTS `$syslogdb_default`.`syslog_reports` ( id int(10) NOT NULL auto_increment, `hash` varchar(32) NOT NULL default '', @@ -662,7 +650,7 @@ function syslog_setup_table_new(array $options, bool $repair = false): void { lastsent int(16) NOT NULL default '0', body varchar(8192) NOT NULL default '0', message TEXT default NULL, - `user` varchar(32) NOT NULL default '', + `user` varchar(50) NOT NULL default '', `date` int(16) NOT NULL default '0', email varchar(255) default NULL, notify int(10) unsigned NOT NULL default '0', @@ -855,7 +843,7 @@ function syslog_create_alert_counts_table(): void { /** Complete an interrupted install or upgrade without dropping existing data. */ function syslog_ensure_table_structures(): bool { - global $config, $syslog_cnn; + global $config, $syslog_cnn, $syslogdb_default; require_once(__DIR__ . '/settings.php'); @@ -925,6 +913,22 @@ function syslog_ensure_table_structures(): bool { syslog_ensure_share_tables(); syslog_ensure_dashboard_tables(); } + // Rule owners are Cacti usernames, which allow 50 characters. The marker is + // written only after every table was widened, so a failed run is retried. + $widened = syslog_db_fetch_cell_prepared("SELECT value FROM `$syslogdb_default`.`syslog_status` WHERE name = ?", ['schema_user_width']); + + if ($widened !== '50') { + foreach (['syslog_alert', 'syslog_remove', 'syslog_reports', 'syslog_device_rule'] as $table) { + if (!syslog_db_execute("ALTER TABLE `$syslogdb_default`.`$table` MODIFY COLUMN `user` varchar(50) NOT NULL DEFAULT ''")) { + return false; + } + } + + if (!syslog_status_set('schema_user_width', '50')) { + return false; + } + } + syslog_ensure_message_digest_columns(); $tables = array_merge($tables, [ 'syslog_saved_searches', 'syslog_saved_searches_perm', diff --git a/includes/settings.php b/includes/settings.php index 724c4119..a8ab6cad 100644 --- a/includes/settings.php +++ b/includes/settings.php @@ -129,7 +129,7 @@ function syslog_config_settings(): void { ], 'syslog_resolve_hostname' => [ 'friendly_name' => __('Enable Hostname Resolution', 'syslog'), - 'description' => __('If this checkbox is set, all hostnames are resolved via DNS lookup first (If enabled). If the DNS lookup fails, the system will attempt to resolve the hostname against the Cacti host table and replace it with the Cacti host description. If both DNS and Cacti lookups fail, records are assigned a prefix \'unresolved-Original_hostname\'.', 'syslog'), + 'description' => __('If this checkbox is set, all hostnames are resolved via DNS lookup first (If enabled), using A and AAAA records. IPv4 and IPv6 addresses need no lookup, and IPv6 addresses are stored in compressed lower case form. If the DNS lookup fails, the system will attempt to resolve the hostname against the Cacti host table and replace it with the Cacti host description. If both DNS and Cacti lookups fail, records are assigned a prefix \'unresolved-Original_hostname\'.', 'syslog'), 'method' => 'checkbox', 'default' => '' ], diff --git a/lib/syslog_dashboard.php b/lib/syslog_dashboard.php index 99609894..20b8b5f8 100644 --- a/lib/syslog_dashboard.php +++ b/lib/syslog_dashboard.php @@ -303,11 +303,13 @@ function syslog_dashboard_timeseries_sql(string $source, string $removal, string $sql = "SELECT bucket, SUM(records) AS records FROM ( (SELECT $select, 'main' AS mtype FROM `$syslogdb_default`.`syslog` AS syslog - WHERE $predicate) + WHERE $predicate + GROUP BY bucket) UNION ALL (SELECT $select, 'remove' AS mtype FROM `$syslogdb_default`.`syslog_removed` AS syslog - WHERE $predicate) + WHERE $predicate + GROUP BY bucket) ) AS buckets GROUP BY bucket ORDER BY bucket"; return $sql; @@ -353,11 +355,13 @@ function syslog_dashboard_breakdown_sql(string $source, string $field, string $r return "SELECT label, SUM(records) AS records FROM ( (SELECT $dimension AS label, COUNT(*) AS records, 'main' AS mtype FROM `$syslogdb_default`.`syslog` AS syslog - WHERE $predicate) + WHERE $predicate + GROUP BY label) UNION ALL (SELECT $dimension AS label, COUNT(*) AS records, 'remove' AS mtype FROM `$syslogdb_default`.`syslog_removed` AS syslog - WHERE $predicate) + WHERE $predicate + GROUP BY label) ) AS grouped GROUP BY label ORDER BY records DESC, label ASC"; } diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 917946ea..e02b8776 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -382,7 +382,7 @@ msgid "What upgrade/install type do you wish to use" msgstr "" #: includes/installer.php -msgid "When you have very large tables, performing a Truncate will be much quicker. If you are concerned about archive data, you can choose either Inline, which will freeze your browser for the period of this upgrade, or background, which will create a background process to bring your old syslog data from a backup table to the new syslog format. Again this process can take several hours." +msgid "When you have very large tables, performing a Truncate will be much quicker. If you are concerned about archive data, you can choose either Inline, which will freeze your browser for the period of this upgrade, or background, which will create a background process to bring your old syslog data from a backup table to the new syslog format. Again this process can take several hours. A Truncate discards the stored syslog and removed messages only; alert, removal and report rules are always kept." msgstr "" #: includes/installer.php @@ -697,7 +697,7 @@ msgid "Enable Hostname Resolution" msgstr "" #: includes/settings.php -msgid "If this checkbox is set, all hostnames are resolved via DNS lookup first (If enabled). If the DNS lookup fails, the system will attempt to resolve the hostname against the Cacti host table and replace it with the Cacti host description. If both DNS and Cacti lookups fail, records are assigned a prefix 'unresolved-Original_hostname'." +msgid "If this checkbox is set, all hostnames are resolved via DNS lookup first (If enabled), using A and AAAA records. IPv4 and IPv6 addresses need no lookup, and IPv6 addresses are stored in compressed lower case form. If the DNS lookup fails, the system will attempt to resolve the hostname against the Cacti host table and replace it with the Cacti host description. If both DNS and Cacti lookups fail, records are assigned a prefix 'unresolved-Original_hostname'." msgstr "" #: includes/settings.php diff --git a/syslog_alerts.php b/syslog_alerts.php index 62d5bb4c..e2b7b66a 100644 --- a/syslog_alerts.php +++ b/syslog_alerts.php @@ -1379,12 +1379,15 @@ function alert_import(): void { if ($import_array !== false && cacti_sizeof($import_array)) { foreach ($import_array as $template => $contents) { - $save = []; + $save = ['id' => 0]; $tname = ''; if (cacti_sizeof($contents)) { foreach ($contents as $name => $value) { switch($name) { + case 'id': + // The row to update is chosen by hash, never by the file. + break; case 'hash': // See if the hash exists, if it does, update the alert $found = syslog_db_fetch_cell_prepared('SELECT id @@ -1416,7 +1419,13 @@ function alert_import(): void { } } - $id = syslog_sql_save($save, 'syslog_alert'); + $id = false; + $check = isset($save['type'], $save['message']) ? syslog_get_alert_sql($save, 100) : []; + + // Imports are held to the same compile and syntax check as the editor. + if (cacti_sizeof($check) && $check['sql'] != '' && syslog_db_fetch_assoc_prepared($check['sql'], $check['params'], false) !== false) { + $id = syslog_sql_save($save, 'syslog_alert'); + } if ($id) { raise_message('syslog_info' . $id, __esc('NOTE: Alert \'%s\' %s!', $tname, ($save['id'] > 0 ? __('Updated', 'syslog') : __('Imported', 'syslog')), 'syslog'), MESSAGE_LEVEL_INFO); diff --git a/syslog_removal.php b/syslog_removal.php index 551f1204..c2625c91 100644 --- a/syslog_removal.php +++ b/syslog_removal.php @@ -422,7 +422,7 @@ function api_syslog_removal_enable(int $id): void { */ function api_syslog_removal_reprocess(int $id): void { // remove records retroactively - $syslog_items = syslog_remove_items('syslog', $id); + $syslog_items = syslog_remove_items('syslog', 0, $id); $syslog_removed = $syslog_items['removed']; $syslog_xferred = $syslog_items['xferred']; diff --git a/syslog_saved_searches.php b/syslog_saved_searches.php index 249d6cbc..ac992302 100644 --- a/syslog_saved_searches.php +++ b/syslog_saved_searches.php @@ -525,6 +525,21 @@ function syslog_saved_search_import() { continue; } + // Imports are held to the same checks as the editor and the save action. + try { + syslog_parse_logical_search((string) $save['search']); + } catch (InvalidArgumentException $error) { + $failed++; + + continue; + } + + if (!in_array((string) $save['removal'], ['-1', '1', '2'], true) || !in_array((string) $save['grouping'], ['0', '1'], true)) { + $failed++; + + continue; + } + if (isset($contents['shared_users']) && is_array($contents['shared_users'])) { $shared_users = $contents['shared_users']; } else { diff --git a/tests/E2E/playwright/browser-bulk-action.js b/tests/E2E/playwright/browser-bulk-action.js new file mode 100644 index 00000000..fe23a6c7 --- /dev/null +++ b/tests/E2E/playwright/browser-bulk-action.js @@ -0,0 +1,47 @@ +// Runs a bulk action on one row of a rule list: the confirmation request and the +// confirmed request, sent from the logged in page with its session and CSRF token. +const { withSession, fail, url } = require('./session'); + +withSession(async (page) => { + await page.goto(url(process.env.PAGE_PATH), { waitUntil: 'domcontentloaded' }); + + const rowIds = await page.evaluate((texts) => texts.map((text) => { + const row = Array.from(document.querySelectorAll('tr')).find((candidate) => candidate.innerText.includes(text)); + const box = row ? row.querySelector('input[type="checkbox"]') : null; + + return box ? box.name : null; + }), process.env.ROW_TEXT.split(',')); + + if (rowIds.includes(null)) { + fail(`No row contains ${process.env.ROW_TEXT}`); + } + + const result = await page.evaluate(async ({ url, rowIds, action }) => { + const send = async (fields) => { + const body = new URLSearchParams({ __csrf_magic: csrfMagicToken, drp_action: action, action: 'actions', ...fields }); + const response = await fetch(url, { method: 'POST', body, credentials: 'same-origin' }); + + return { status: response.status, text: await response.text() }; + }; + + const confirm = await send(Object.fromEntries(rowIds.map((name) => [name, 'on']))); + const selected = new DOMParser().parseFromString(confirm.text, 'text/html').querySelector('input[name="selected_items"]'); + + if (!selected) { + return { error: 'The confirmation page did not list the selected rule' }; + } + + const done = await send({ selected_items: selected.value }); + + return { status: done.status }; + }, { url: url(process.env.PAGE_PATH), rowIds, action: process.env.ACTION_VALUE }); + + if (result.error || result.status !== 200) { + fail(result.error || `Unexpected status ${result.status}`); + } + + console.log(`ok action ${process.env.ACTION_VALUE}`); +}).catch((error) => { + console.error(error.stack || String(error)); + process.exit(1); +}); diff --git a/tests/E2E/playwright/browser-check.js b/tests/E2E/playwright/browser-check.js index 4d269234..e29edeab 100644 --- a/tests/E2E/playwright/browser-check.js +++ b/tests/E2E/playwright/browser-check.js @@ -12,7 +12,7 @@ async function login(page) { await page.locator('#login_password').fill('Admin123!'); await Promise.all([ page.waitForURL(/\/cacti\/index\.php/, { timeout: 30000 }), - page.locator('form#auth').evaluate((form) => form.submit()) + page.locator('#login_password').evaluate((field) => HTMLFormElement.prototype.submit.call(field.form)) ]); } diff --git a/tests/E2E/playwright/browser-import.js b/tests/E2E/playwright/browser-import.js new file mode 100644 index 00000000..5724ba78 --- /dev/null +++ b/tests/E2E/playwright/browser-import.js @@ -0,0 +1,42 @@ +// Pastes an import document into a rule import form and checks the outcome message. +const { withSession, fail, url } = require('./session'); + +withSession(async (page) => { + await page.goto(url(process.env.PAGE_PATH), { waitUntil: 'domcontentloaded' }); + const padding = Number(process.env.IMPORT_PAD_BYTES || 0); + const text = (process.env.IMPORT_TEXT || '') + ' '.repeat(padding); + + if (process.env.IMPORT_AS_FILE === '1') { + await page.locator('#import_file').setInputFiles({ name: 'import.json', mimeType: 'application/json', buffer: Buffer.from(text) }); + } else { + await page.locator('#import_text').fill(text); + } + + // On newer Cacti cores the Import button and the action field are rendered with + // an empty form attribute, which detaches them from the form. The form is + // submitted directly with the action field added back. + await Promise.all([ + page.waitForNavigation({ waitUntil: 'domcontentloaded' }), + page.locator('#import_text').evaluate((field) => { + const action = document.createElement('input'); + + action.type = 'hidden'; + action.name = 'action'; + action.value = 'save'; + field.form.appendChild(action); + HTMLFormElement.prototype.submit.call(field.form); + }) + ]); + await page.waitForLoadState('networkidle'); + + const body = await page.locator('body').innerText(); + + if (!body.includes(process.env.EXPECT_TEXT || '')) { + fail(`Expected the page to contain "${process.env.EXPECT_TEXT}", got: ${body.slice(0, 400)}`); + } + + console.log(`ok ${process.env.PAGE_PATH}`); +}).catch((error) => { + console.error(error.stack || String(error)); + process.exit(1); +}); diff --git a/tests/E2E/playwright/browser-json-check.js b/tests/E2E/playwright/browser-json-check.js new file mode 100644 index 00000000..e8e05014 --- /dev/null +++ b/tests/E2E/playwright/browser-json-check.js @@ -0,0 +1,43 @@ +// Fetches a JSON endpoint as the logged in admin and checks the labels it returns. +const { withSession, fail, url } = require('./session'); + +withSession(async (page) => { + const path = process.env.JSON_PATH || ''; + const response = await page.evaluate(async (url) => { + const result = await fetch(url, { credentials: 'same-origin' }); + return { status: result.status, text: await result.text() }; + }, url(path)); + + if (response.status !== 200) { + fail(`Expected HTTP 200 from ${path}, got ${response.status}`); + } + + const payload = JSON.parse(response.text); + + if (payload.error) { + fail(`Endpoint reported an error: ${payload.error}`); + } + + const labels = (payload.labels || []).join('|'); + + for (const unwanted of (process.env.ABSENT_LABELS || '').split(',').filter(Boolean)) { + if (labels.includes(unwanted)) { + fail(`Expected labels not to contain ${unwanted}, got ${labels}`); + } + } + + if (process.env.EXPECT_TOTAL !== undefined && Number(payload.total) !== Number(process.env.EXPECT_TOTAL)) { + fail(`Expected a total of ${process.env.EXPECT_TOTAL}, got ${payload.total}`); + } + + for (const wanted of (process.env.EXPECT_LABELS || '').split(',').filter(Boolean)) { + if (!labels.includes(wanted)) { + fail(`Expected labels to contain ${wanted}, got ${labels}`); + } + } + + console.log(`ok ${path}`); +}).catch((error) => { + console.error(error.stack || String(error)); + process.exit(1); +}); diff --git a/tests/E2E/playwright/browser-json-post.js b/tests/E2E/playwright/browser-json-post.js new file mode 100644 index 00000000..e2b27974 --- /dev/null +++ b/tests/E2E/playwright/browser-json-post.js @@ -0,0 +1,41 @@ +// Posts form fields to a plugin JSON endpoint as the signed in user and checks the reply. +const { withSession, fail, url } = require('./session'); + +withSession(async (page) => { + const fields = JSON.parse(process.env.JSON_FIELDS || '{}'); + const reply = await page.evaluate(async ({ target, fields }) => { + const body = new URLSearchParams({ __csrf_magic: csrfMagicToken, ...fields }); + const response = await fetch(target, { method: 'POST', body, credentials: 'same-origin' }); + + return { status: response.status, text: await response.text() }; + }, { target: url(process.env.JSON_PATH), fields }); + + if (reply.status !== 200) { + fail(`Expected HTTP 200 from ${process.env.JSON_PATH}, got ${reply.status}`); + } + + let payload; + + try { + payload = JSON.parse(reply.text); + } catch (error) { + fail(`The reply is not JSON: ${reply.text.replace(/\s+/g, " ").slice(0, 300)}`); + } + + const wantError = process.env.EXPECT_ERROR === '1'; + + if (wantError !== Boolean(payload.error)) { + fail(`Expected ${wantError ? 'an error' : 'no error'}, got: ${reply.text}`); + } + + for (const key of (process.env.EXPECT_KEYS || '').split(',').filter(Boolean)) { + if (!(key in payload)) { + fail(`Expected the reply to contain ${key}, got: ${reply.text}`); + } + } + + console.log(reply.text); +}).catch((error) => { + console.error(error.stack || String(error)); + process.exit(1); +}); diff --git a/tests/E2E/playwright/package.json b/tests/E2E/playwright/package.json index 507c9c8b..9a999231 100644 --- a/tests/E2E/playwright/package.json +++ b/tests/E2E/playwright/package.json @@ -3,6 +3,6 @@ "private": true, "version": "1.0.0", "devDependencies": { - "playwright": "1.55.1" + "playwright": "1.52.0" } } diff --git a/tests/E2E/playwright/run-e2e.sh b/tests/E2E/playwright/run-e2e.sh index ad0b745f..6ab535cf 100755 --- a/tests/E2E/playwright/run-e2e.sh +++ b/tests/E2E/playwright/run-e2e.sh @@ -7,6 +7,8 @@ PW_PREFIX=( docker run --rm --network cacti-syslog-e2e_default --ipc=host + -e E2E_SCHEME=http + -e E2E_HOST=cacti_web -v "${ROOT_DIR}/tests/E2E/playwright:/work" \ -w /work ) @@ -38,6 +40,34 @@ browser_check() { node browser-check.js >/dev/null } +node_check() { + local script="$1" + shift + + local env_args=() + local pair + + for pair in "$@"; do + env_args+=(-e "$pair") + done + + "${PW_PREFIX[@]}" "${env_args[@]}" "$PW_IMAGE" node "$script" >/dev/null +} + +node_check_as() { + local script="$1" + shift + + local env_args=() + local pair + + for pair in "$@"; do + env_args+=(-e "$pair") + done + + "${PW_PREFIX[@]}" -e "E2E_USER=${E2E_USER}" -e "E2E_PASSWORD=Admin123!" "${env_args[@]}" "$PW_IMAGE" node "$script" >/dev/null +} + run_test() { local name="$1" shift @@ -63,7 +93,7 @@ normalize_admin() { } reset_syslog_data() { - db_exec "TRUNCATE syslog_incoming; TRUNCATE syslog; TRUNCATE syslog_removed; TRUNCATE syslog_statistics; TRUNCATE syslog_hosts; TRUNCATE syslog_programs; TRUNCATE syslog_host_facilities; DELETE FROM syslog_remove;" + db_exec "TRUNCATE syslog_incoming; TRUNCATE syslog; TRUNCATE syslog_removed; TRUNCATE syslog_hosts; TRUNCATE syslog_programs; TRUNCATE syslog_host_facilities; DELETE FROM syslog_remove; DELETE FROM syslog_alert; DELETE FROM syslog_logs; DELETE FROM syslog_saved_searches;" } seed_incoming() { @@ -83,7 +113,7 @@ test_login_console() { test_syslog_empty_page() { reset_syslog_data - browser_check "plugins/syslog/syslog.php" "Console > Syslog" "No Syslog Messages" "Unprocessed Messages: 0" + browser_check "plugins/syslog/syslog.php" "Syslog" "No Syslog Messages" "Unprocessed Messages: 0" } test_alerts_page() { @@ -111,7 +141,7 @@ test_plain_ingest_normalization() { } test_plain_ingest_visible_in_ui() { - browser_check "plugins/syslog/syslog.php?rfilter=E2E-TOKEN-ONE" "Console > Syslog" "E2E-TOKEN-ONE" "e2e-host-1" "e2e-prog-1" + browser_check "plugins/syslog/syslog.php?rfilter=E2E-TOKEN-ONE" "Syslog" "E2E-TOKEN-ONE" "e2e-host-1" "e2e-prog-1" } test_second_ingest_accumulates() { @@ -123,7 +153,175 @@ test_second_ingest_accumulates() { } test_second_ingest_visible_in_ui() { - browser_check "plugins/syslog/syslog.php?rfilter=E2E-TOKEN-TWO" "Console > Syslog" "E2E-TOKEN-TWO" "e2e-host-2" "e2e-prog-2" + browser_check "plugins/syslog/syslog.php?rfilter=E2E-TOKEN-TWO" "Syslog" "E2E-TOKEN-TWO" "e2e-host-2" "e2e-prog-2" +} + +test_dashboard_all_records_lists_every_host() { + local panel_id + panel_id="$(db_scalar "SELECT id FROM syslog_dashboard_panels WHERE title = 'Top 10 Hosts' LIMIT 1")" + node_check browser-json-check.js \ + "JSON_PATH=plugins/syslog/syslog.php?action=dashboard_chart&panel_id=${panel_id}&dashboard_timespan=86400" \ + "EXPECT_LABELS=e2e-host-1,e2e-host-2" +} + +test_archive_rule_on_host_moves_messages() { + db_exec "DELETE FROM syslog_remove; INSERT INTO syslog_remove (hash, name, type, message, method, enabled, user, date) VALUES (MD5('e2e archive host'), 'e2e archive host', 'host', 'e2e-host-3', 'trans', 'on', 'admin', UNIX_TIMESTAMP())" + seed_incoming "e2e-host-3" "e2e-prog-3" "E2E-TOKEN-ARCHIVE archived by host" + seed_incoming "e2e-host-4" "e2e-prog-3" "E2E-TOKEN-KEEP stays in the main table" + run_processor + assert_equals "$(db_scalar "SELECT COUNT(*) FROM syslog_removed WHERE message = 'E2E-TOKEN-ARCHIVE archived by host'")" "1" "host rule should archive its message" + assert_equals "$(db_scalar "SELECT COUNT(*) FROM syslog WHERE message = 'E2E-TOKEN-KEEP stays in the main table'")" "1" "other hosts should reach the main table" + assert_equals "$(db_scalar "SELECT COUNT(*) FROM syslog_incoming")" "0" "incoming queue should be drained" +} + +test_failing_alert_rule_does_not_silence_others() { + db_exec "DELETE FROM syslog_alert; DELETE FROM syslog_logs; INSERT INTO syslog_alert (hash, name, type, message, method, num, enabled, severity, suppression_schedule, user, date) VALUES (MD5('e2e good'), 'e2e good', 'sql', 'message LIKE ''%E2E-TOKEN-ALERT%''', 0, 1, 'on', 1, '', 'admin', UNIX_TIMESTAMP()), (MD5('e2e bad'), 'e2e bad', 'sql', 'message LIKE ''%x'' AND (', 0, 1, 'on', 1, '', 'admin', UNIX_TIMESTAMP())" + seed_incoming "e2e-host-5" "e2e-prog-5" "E2E-TOKEN-ALERT should raise the valid rule" + run_processor + assert_equals "$(db_scalar "SELECT COUNT(*) FROM syslog_logs WHERE logmsg LIKE 'E2E-TOKEN-ALERT%'")" "1" "valid rule should still raise its alert" +} + +test_import_rejects_alert_that_does_not_run() { + db_exec "DELETE FROM syslog_alert" + node_check browser-import.js \ + "PAGE_PATH=plugins/syslog/syslog_alerts.php?action=import" \ + 'IMPORT_TEXT={"version":1,"table":"syslog_alert","templates":[{"hash":"0123456789abcdef0123456789abcdef","name":"e2e broken","severity":1,"method":0,"suppression_schedule":"","type":"sql","message":"message LIKE ("}]}' \ + "EXPECT_TEXT=Alert Filters" + assert_equals "$(db_scalar "SELECT COUNT(*) FROM syslog_alert")" "0" "an alert that cannot run should not be imported" +} + +test_import_rejects_saved_search_that_does_not_parse() { + db_exec "DELETE FROM syslog_saved_searches" + node_check browser-import.js \ + "PAGE_PATH=plugins/syslog/syslog_saved_searches.php?action=import" \ + 'IMPORT_TEXT={"version":1,"table":"syslog_saved_searches","templates":[{"name":"e2e broken","search":"host = \"unterminated"}]}' \ + "EXPECT_TEXT=Saved Search Templates" + assert_equals "$(db_scalar "SELECT COUNT(*) FROM syslog_saved_searches")" "0" "a search that does not parse should not be imported" +} + +test_reprocess_runs_only_the_selected_rule() { + db_exec "DELETE FROM syslog_remove; DELETE FROM syslog; INSERT INTO syslog (logtime, host_id, program_id, facility_id, priority_id, message) VALUES (NOW(), 1, 1, 1, 6, 'E2E-REPROCESS alpha line'), (NOW(), 1, 1, 1, 6, 'E2E-REPROCESS beta line')" + db_exec "INSERT INTO syslog_remove (hash, name, type, message, method, enabled, user, date) VALUES (MD5('e2e alpha rule'), 'e2e alpha rule', 'messagec', 'alpha line', 'del', 'on', 'admin', UNIX_TIMESTAMP()), (MD5('e2e beta rule'), 'e2e beta rule', 'messagec', 'beta line', 'del', 'on', 'admin', UNIX_TIMESTAMP())" + node_check browser-bulk-action.js \ + "PAGE_PATH=plugins/syslog/syslog_removal.php" \ + "ROW_TEXT=e2e alpha rule" \ + "ACTION_VALUE=4" + assert_equals "$(db_scalar "SELECT COUNT(*) FROM syslog WHERE message = 'E2E-REPROCESS alpha line'")" "0" "the selected rule should remove its messages" + assert_equals "$(db_scalar "SELECT COUNT(*) FROM syslog WHERE message = 'E2E-REPROCESS beta line'")" "1" "the other rule should not run" +} + +long_username() { + printf 'u%.0s' $(seq 50) +} + +create_long_user() { + local name + name="$(long_username)" + docker exec cacti_web php -r 'require "include/global.php"; $admin = db_fetch_row("SELECT * FROM user_auth WHERE username = \"admin\""); $old = $admin["id"]; unset($admin["id"]); $admin["username"] = $argv[1]; $admin["password"] = password_hash("Admin123!", PASSWORD_BCRYPT); $admin["must_change_password"] = ""; $admin["password_change"] = ""; db_execute_prepared("DELETE FROM user_auth WHERE username = ?", [$argv[1]]); $id = sql_save($admin, "user_auth"); db_execute_prepared("INSERT INTO user_auth_realm (user_id, realm_id) SELECT ?, realm_id FROM user_auth_realm WHERE user_id = ?", [$id, $old]);' "$name" >/dev/null +} + +import_check() { + local page="$1" text="$2" expect="$3" pad="${4:-0}" as_file="${5:-0}" + node_check browser-import.js "PAGE_PATH=${page}" "IMPORT_TEXT=${text}" "EXPECT_TEXT=${expect}" "IMPORT_PAD_BYTES=${pad}" "IMPORT_AS_FILE=${as_file}" +} + +ALERT_IMPORT_PAGE="plugins/syslog/syslog_alerts.php?action=import" +SEARCH_IMPORT_PAGE="plugins/syslog/syslog_saved_searches.php?action=import" + +alert_file() { + # alert_file [extra json members] + printf '{"version":1,"table":"syslog_alert","templates":[{"hash":"%s","name":"%s","severity":1,"method":0,"suppression_schedule":"","type":"%s","message":"%s"%s}]}' "$1" "$2" "$3" "$4" "${5:-}" +} + +test_alert_import_paths() { + db_exec "DELETE FROM syslog_alert" + import_check "$ALERT_IMPORT_PAGE" "$(alert_file 11111111111111111111111111111111 'e2e import one' messagec error)" "Alert Filters" + assert_equals "$(db_scalar "SELECT COUNT(*) FROM syslog_alert WHERE name = 'e2e import one'")" "1" "a valid pasted file should import" + import_check "$ALERT_IMPORT_PAGE" "$(alert_file 11111111111111111111111111111111 'e2e import one' messagec changed ',"id":9999')" "Alert Filters" + assert_equals "$(db_scalar "SELECT CONCAT(COUNT(*), ':', MAX(message)) FROM syslog_alert")" "1:changed" "the same hash should update that rule and ignore an id in the file" + import_check "$ALERT_IMPORT_PAGE" "$(alert_file 22222222222222222222222222222222 'e2e import file' messagec fromfile)" "Alert Filters" 0 1 + assert_equals "$(db_scalar "SELECT COUNT(*) FROM syslog_alert WHERE name = 'e2e import file'")" "1" "a valid uploaded file should import" + import_check "$ALERT_IMPORT_PAGE" 'not json at all' "Alert Filters" + import_check "$ALERT_IMPORT_PAGE" '{"version":1,"table":"syslog_alert","templates":[' "Alert Filters" + import_check "$ALERT_IMPORT_PAGE" '{"version":1,"table":"syslog_remove","templates":[{"name":"x","method":"del","message":"y"}]}' "Alert Filters" + import_check "$ALERT_IMPORT_PAGE" "$(alert_file 33333333333333333333333333333333 'e2e bad sql' sql 'message LIKE (')" "Alert Filters" + import_check "$ALERT_IMPORT_PAGE" "$(alert_file 44444444444444444444444444444444 'e2e bad type' nonsense error)" "Alert Filters" + import_check "$ALERT_IMPORT_PAGE" "$(alert_file 55555555555555555555555555555555 'e2e bad filter' filter '{\"version\":1,\"conditions\":[{\"field\":\"nope\"}]}')" "Alert Filters" + import_check "$ALERT_IMPORT_PAGE" "$(alert_file 66666666666666666666666666666666 'e2e oversized' messagec error)" "Alert Filters" 6000000 + import_check "$ALERT_IMPORT_PAGE" "$(alert_file 77777777777777777777777777777777 'e2e oversized file' messagec error)" "Alert Filters" 6000000 1 + assert_equals "$(db_scalar "SELECT COUNT(*) FROM syslog_alert")" "2" "malformed, hostile and oversized files should import nothing" +} + +test_saved_search_import_paths() { + db_exec "DELETE FROM syslog_saved_searches" + local good='{"version":1,"table":"syslog_saved_searches","templates":[{"name":"e2e search","search":"message contains \"error\"","removal":1,"grouping":0}]}' + import_check "$SEARCH_IMPORT_PAGE" "$good" "Saved Search Templates" + assert_equals "$(db_scalar "SELECT COUNT(*) FROM syslog_saved_searches")" "1" "a valid search should import" + import_check "$SEARCH_IMPORT_PAGE" '{"version":1,"table":"syslog_saved_searches","templates":[{"name":"e2e quotes","search":"message contains \"a'"'"'); DROP TABLE syslog; --\"","removal":1,"grouping":0}]}' "Saved Search Templates" + assert_equals "$(db_scalar "SELECT COUNT(*) FROM syslog_saved_searches")" "2" "text that looks like SQL should be stored as text" + assert_equals "$(db_scalar "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'cacti' AND table_name = 'syslog'")" "1" "the message table should still exist" + import_check "$SEARCH_IMPORT_PAGE" 'not json at all' "Saved Search Templates" + import_check "$SEARCH_IMPORT_PAGE" '{"version":1,"table":"syslog_saved_searches","templates":[{"name":"e2e open","search":"host = \"open","removal":1,"grouping":0}]}' "Saved Search Templates" + import_check "$SEARCH_IMPORT_PAGE" '{"version":1,"table":"syslog_saved_searches","templates":[{"name":"e2e type","search":"message contains \"x\"","removal":9,"grouping":0}]}' "Saved Search Templates" + import_check "$SEARCH_IMPORT_PAGE" '{"version":1,"table":"syslog_saved_searches","templates":[{"name":"e2e group","search":"message contains \"x\"","removal":1,"grouping":4}]}' "Saved Search Templates" + import_check "$SEARCH_IMPORT_PAGE" "$good" "Saved Search Templates" 6000000 + import_check "$SEARCH_IMPORT_PAGE" "$good" "Saved Search Templates" 6000000 1 + assert_equals "$(db_scalar "SELECT COUNT(*) FROM syslog_saved_searches")" "2" "invalid and oversized search files should import nothing" +} + +test_reprocess_selected_rules_among_several() { + db_exec "DELETE FROM syslog_remove; DELETE FROM syslog; INSERT INTO syslog (logtime, host_id, program_id, facility_id, priority_id, message) VALUES (NOW(), 1, 1, 1, 6, 'E2E-MULTI alpha line'), (NOW(), 1, 1, 1, 6, 'E2E-MULTI beta line'), (NOW(), 1, 1, 1, 6, 'E2E-MULTI gamma line'), (NOW(), 1, 1, 1, 6, 'E2E-MULTI delta line')" + db_exec "INSERT INTO syslog_remove (hash, name, type, message, method, enabled, user, date) VALUES (MD5('m alpha'), 'e2e multi alpha', 'messagec', 'alpha line', 'del', 'on', 'admin', UNIX_TIMESTAMP()), (MD5('m beta'), 'e2e multi beta', 'messagec', 'beta line', 'del', '', 'admin', UNIX_TIMESTAMP()), (MD5('m gamma'), 'e2e multi gamma', 'messagec', 'gamma line', 'trans', 'on', 'admin', UNIX_TIMESTAMP()), (MD5('m delta'), 'e2e multi delta', 'messagec', 'delta line', 'del', 'on', 'admin', UNIX_TIMESTAMP())" + node_check browser-bulk-action.js "PAGE_PATH=plugins/syslog/syslog_removal.php" "ROW_TEXT=e2e multi alpha,e2e multi gamma,e2e multi beta" "ACTION_VALUE=4" + assert_equals "$(db_scalar "SELECT GROUP_CONCAT(SUBSTRING_INDEX(message, ' ', -2) ORDER BY message) FROM syslog WHERE message LIKE 'E2E-MULTI%'")" "beta line,delta line" "only the selected enabled rules should run" + assert_equals "$(db_scalar "SELECT COUNT(*) FROM syslog_removed WHERE message = 'E2E-MULTI gamma line'")" "1" "a selected move rule should archive its message" +} + +seed_dashboard_data() { + db_exec "TRUNCATE syslog; TRUNCATE syslog_removed; DELETE FROM syslog_hosts; DELETE FROM syslog_programs; INSERT INTO syslog_hosts (host_id, host) VALUES (1, 'dash-host-a'), (2, 'dash-host-b'), (3, 'dash-host-c'); INSERT INTO syslog_programs (program_id, program) VALUES (1, 'dash-prog-a'), (2, 'dash-prog-b')" + db_exec "INSERT INTO syslog (logtime, host_id, program_id, facility_id, priority_id, message) VALUES (NOW(), 1, 1, 1, 3, 'm'), (NOW(), 1, 2, 1, 4, 'm'), (NOW(), 2, 1, 16, 3, 'm')" + db_exec "INSERT INTO syslog_removed (logtime, host_id, program_id, facility_id, priority_id, message) VALUES (NOW(), 3, 2, 16, 4, 'm'), (NOW(), 3, 2, 16, 4, 'm')" + db_exec "DELETE FROM syslog_dashboard_panels WHERE title LIKE 'e2e panel%'" +} + +dashboard_panel_check() { + # dashboard_panel_check + local removal="$1" field="$2" expect="$3" absent="$4" total="$5" panel_id + db_exec "INSERT INTO syslog_dashboard_panels (dashboard_id, title, expression, source, removal, kind, chart, field, \`interval\`, timespan, top_n, width, height, position, date) SELECT MIN(id), 'e2e panel ${removal} ${field}', '', 'syslog', ${removal}, 'breakdown', 'donut', '${field}', 'auto', 'dashboard', 10, 1, 0, 90, UNIX_TIMESTAMP() FROM syslog_dashboards" + panel_id="$(db_scalar "SELECT MAX(id) FROM syslog_dashboard_panels WHERE title = 'e2e panel ${removal} ${field}'")" + node_check browser-json-check.js \ + "JSON_PATH=plugins/syslog/syslog.php?action=dashboard_chart&panel_id=${panel_id}&dashboard_timespan=86400" \ + "EXPECT_LABELS=${expect}" "ABSENT_LABELS=${absent}" "EXPECT_TOTAL=${total}" +} + +test_dashboards_every_record_type_and_dimension() { + seed_dashboard_data + dashboard_panel_check -1 host "dash-host-a,dash-host-b" "dash-host-c" 3 + dashboard_panel_check 2 host "dash-host-c" "dash-host-a,dash-host-b" 2 + dashboard_panel_check 1 host "dash-host-a,dash-host-b,dash-host-c" "" 5 + dashboard_panel_check -1 program "dash-prog-a,dash-prog-b" "" 3 + dashboard_panel_check 2 program "dash-prog-b" "dash-prog-a" 2 + dashboard_panel_check 1 program "dash-prog-a,dash-prog-b" "" 5 + dashboard_panel_check -1 priority "err,warning" "" 3 + dashboard_panel_check 2 priority "warning" "err" 2 + dashboard_panel_check 1 priority "err,warning" "" 5 + dashboard_panel_check -1 facility "user,local0" "" 3 + dashboard_panel_check 2 facility "local0" "user" 2 + dashboard_panel_check 1 facility "user,local0" "" 5 +} + +test_long_username_owns_templates() { + local owner + owner="$(long_username)" + create_long_user + db_exec "DELETE FROM syslog_saved_searches WHERE user = '${owner}'; DELETE FROM syslog_dashboards WHERE user = '${owner}'" + E2E_USER="$owner" node_check_as browser-json-post.js "JSON_PATH=plugins/syslog/syslog.php?action=saved_search_save" 'JSON_FIELDS={"name":"e2e long owner","rfilter":"message contains \"x\"","tab":"syslog","removal":"1","grouping":"0"}' "EXPECT_KEYS=id" + assert_equals "$(db_scalar "SELECT COUNT(*) FROM syslog_saved_searches WHERE user = '${owner}' AND name = 'e2e long owner'")" "1" "a 50 character user name should own its saved search" + E2E_USER="$owner" node_check_as browser-json-post.js "JSON_PATH=plugins/syslog/syslog.php?action=dashboard_save" 'JSON_FIELDS={"name":"e2e long owner dashboard"}' "EXPECT_KEYS=id" + assert_equals "$(db_scalar "SELECT COUNT(*) FROM syslog_dashboards WHERE user = '${owner}'")" "1" "a 50 character user name should own its dashboard" + E2E_USER="$owner" node_check_as browser-json-post.js "JSON_PATH=plugins/syslog/syslog.php?action=saved_search_save" 'JSON_FIELDS={"name":"","rfilter":"message contains \"x\"","tab":"syslog"}' "EXPECT_ERROR=1" + E2E_USER="$owner" node_check_as browser-json-post.js "JSON_PATH=plugins/syslog/syslog.php?action=saved_search_save" 'JSON_FIELDS={"name":"e2e bad search","rfilter":"host = \"open","tab":"syslog"}' "EXPECT_ERROR=1" + assert_equals "$(db_scalar "SELECT COUNT(*) FROM syslog_saved_searches WHERE user = '${owner}'")" "1" "rejected saves should not add rows" } normalize_admin @@ -139,4 +337,17 @@ run_test "8 plain ingest visible in ui" test_plain_ingest_visible_in_ui run_test "9 second ingest accumulates" test_second_ingest_accumulates run_test "10 second ingest visible in ui" test_second_ingest_visible_in_ui +run_test "11 dashboard all records lists every host" test_dashboard_all_records_lists_every_host +run_test "12 archive rule on host moves messages" test_archive_rule_on_host_moves_messages +run_test "13 failing alert rule does not silence others" test_failing_alert_rule_does_not_silence_others +run_test "14 import rejects an alert that does not run" test_import_rejects_alert_that_does_not_run +run_test "15 import rejects a saved search that does not parse" test_import_rejects_saved_search_that_does_not_parse +run_test "16 reprocess runs only the selected rule" test_reprocess_runs_only_the_selected_rule + +run_test "17 alert import paths" test_alert_import_paths +run_test "18 saved search import paths" test_saved_search_import_paths +run_test "19 reprocess selected rules among several" test_reprocess_selected_rules_among_several +run_test "20 dashboards for every record type and dimension" test_dashboards_every_record_type_and_dimension +run_test "21 a 50 character user name owns its templates" test_long_username_owns_templates + echo "ALL PASS ${pass_count}" diff --git a/tests/E2E/playwright/session.js b/tests/E2E/playwright/session.js new file mode 100644 index 00000000..0200cea4 --- /dev/null +++ b/tests/E2E/playwright/session.js @@ -0,0 +1,45 @@ +const { chromium } = require('playwright'); + +// The address is built from the environment set by run-e2e.sh. +function url(path = '') { + const scheme = process.env.E2E_SCHEME; + const host = process.env.E2E_HOST; + + if (!scheme || !host) { + throw new Error('E2E_SCHEME and E2E_HOST must be set'); + } + + return `${scheme}://${host}/cacti/${path}`; +} + +async function login(page) { + await page.goto(url(), { waitUntil: 'domcontentloaded' }); + // The form limits the field to 25 characters; setting the value directly sends the full name. + await page.locator('#login_username').evaluate((field, name) => { field.value = name; }, process.env.E2E_USER || 'admin'); + await page.locator('#login_password').fill(process.env.E2E_PASSWORD || 'Admin123!'); + await Promise.all([ + page.waitForURL(/\/cacti\/index\.php/, { timeout: 30000 }), + page.locator('#login_password').evaluate((field) => HTMLFormElement.prototype.submit.call(field.form)) + ]); +} + +async function withSession(run) { + const browser = await chromium.launch({ + headless: true, + args: ['--no-sandbox', '--disable-dev-shm-usage'] + }); + + try { + const page = await browser.newPage(); + await login(page); + await run(page); + } finally { + await browser.close(); + } +} + +function fail(message) { + throw new Error(message); +} + +module.exports = { withSession, fail, url }; diff --git a/tests/Unit/AlertCommandTokensTest.php b/tests/Unit/AlertCommandTokensTest.php new file mode 100644 index 00000000..481da9d3 --- /dev/null +++ b/tests/Unit/AlertCommandTokensTest.php @@ -0,0 +1,48 @@ + 'Critical']; + $syslog_levels = [3 => 'err']; + $syslog_facilities = [1 => 'user']; +} + +it('passes a value that contains a token name through unchanged', function () { + alert_command_tokens_setup(); + + $alert = ['id' => 5, 'severity' => 2, 'command' => '/bin/echo ']; + $results = ['priority_id' => 3, 'facility_id' => 1, 'message' => 'link down']; + + $command = alert_replace_variables($alert, $results, 'ab'); + + expect($command)->toBe("/bin/echo 'ab' 'link down'"); + + // The shell must see exactly two arguments. + exec('printf "%s\n" ' . substr($command, strlen('/bin/echo ')), $lines); + + expect($lines)->toBe(['ab', 'link down']); +}); + +it('substitutes every documented token', function () { + alert_command_tokens_setup(); + + $alert = ['id' => 7, 'severity' => 2, 'command' => '/x ']; + $results = ['priority_id' => 3, 'facility_id' => 1, 'message' => 'link down']; + + expect(alert_replace_variables($alert, $results, 'r1'))->toBe("/x '7' 'r1' 'err' 'user' 'link down' 'Critical'"); +}); diff --git a/tests/Unit/AlertHostLevelSqlTest.php b/tests/Unit/AlertHostLevelSqlTest.php new file mode 100644 index 00000000..07f0a1bc --- /dev/null +++ b/tests/Unit/AlertHostLevelSqlTest.php @@ -0,0 +1,75 @@ + 'Error']; + $GLOBALS['syslog_levels'] = [3 => 'Error']; + $GLOBALS['syslog_facilities'] = [1 => 'User']; + $GLOBALS['syslog_incoming_config'] = [ + 'textField' => 'message', 'hostField' => 'host', 'programField' => 'program', + 'facilityField' => 'facility_id', 'priorityField' => 'priority_id', + ]; +} + + +function host_level_rule(int $id, string $message, int $level = 0): array { + return [ + 'id' => $id, 'name' => 'Rule ' . $id, 'type' => 'sql', 'message' => $message, + 'method' => 1, 'num' => 1, 'level' => $level, 'threshold_window' => 0, + 'maintenance_mode' => 'disabled', 'email' => '', 'notify' => 0, + 'repeat_alert' => 0, 'command' => '', 'severity' => 3, 'body' => '', + 'ticket' => '', 'open_ticket' => '', 'date' => 1, + ]; +} + + +it('keeps asterisks inside a host-level rule expression intact', function () { + host_level_setup(); + + $rule = host_level_rule(31, "message REGEXP 'link.*down'", 1); + + test_override('syslog_db_fetch_assoc', static fn($sql) => str_contains($sql, "enabled = 'on'") ? [$rule] : []); + + $queries = []; + + test_override('syslog_db_fetch_assoc_prepared', function ($sql) use (&$queries) { + $queries[] = $sql; + + if (str_contains($sql, 'SUM(CASE WHEN')) { + return [['m0' => 3]]; + } + + return []; + }); + test_override('syslog_db_fetch_cell_prepared', static fn($sql) => str_contains($sql, 'GET_LOCK') ? 1 : 0); + test_override('syslog_db_fetch_row_prepared', static fn() => []); + test_override('syslog_db_execute', static fn() => true); + test_override('syslog_db_execute_prepared', static fn() => true); + + syslog_process_alerts(100); + + $grouped = array_values(array_filter($queries, static fn($sql) => str_contains($sql, 'GROUP BY host'))); + + expect($grouped)->toHaveCount(1) + ->and($grouped[0])->toContain("REGEXP 'link.*down'") + ->and($grouped[0])->toStartWith('SELECT host, COUNT(*) AS count'); +}); diff --git a/tests/Unit/AlertImportValidationTest.php b/tests/Unit/AlertImportValidationTest.php new file mode 100644 index 00000000..a134d376 --- /dev/null +++ b/tests/Unit/AlertImportValidationTest.php @@ -0,0 +1,98 @@ + 'Error']; + $GLOBALS['syslog_levels'] = [3 => 'Error']; + $GLOBALS['syslog_facilities'] = [1 => 'User']; + $GLOBALS['syslog_incoming_config'] = [ + 'textField' => 'message', 'hostField' => 'host', 'programField' => 'program', + 'facilityField' => 'facility_id', 'priorityField' => 'priority_id', + ]; +} + +function alert_import_run(array $templates, array $dependencies = []): array { + alert_import_setup(); + + $import = syslog_test_extract_function('syslog_alerts.php', 'alert_import'); + + $GLOBALS['request']['import_text'] = json_encode(['version' => 1, 'table' => 'syslog_alert', 'templates' => $templates]); + + test_override('db_fetch_assoc_prepared', static fn($sql) => str_contains($sql, 'plugin_realms') ? [['id' => 2]] : []); + test_override('syslog_db_column_exists', static fn() => true); + test_override('syslog_db_fetch_cell_prepared', static fn($sql, $params) => ($params[0] ?? '') === 'known-hash' ? 9 : false); + test_override('syslog_db_fetch_assoc_prepared', $dependencies['query'] ?? static fn() => []); + + $saved = []; + + test_override('sql_save', function ($row) use (&$saved) { + $saved[] = $row; + + return 1; + }); + + $import(); + + return $saved; +} + +it('does not let an imported file choose the row id', function () { + $saved = alert_import_run([[ + 'hash' => 'known-hash', 'id' => 77, 'name' => 'Imported', 'severity' => 3, 'method' => 0, + 'type' => 'messagec', 'message' => 'error', + ]]); + + expect($saved)->toHaveCount(1) + ->and((int) $saved[0]['id'])->toBe(9); +}); + +it('rejects an imported alert whose query does not run', function () { + $saved = alert_import_run([[ + 'name' => 'Broken', 'severity' => 3, 'method' => 0, 'type' => 'sql', 'message' => 'message LIKE (', + ]], ['query' => static fn() => false]); + + expect($saved)->toBe([]); +}); + +it('rejects an imported alert without a usable match type', function () { + $saved = alert_import_run([[ + 'name' => 'Untyped', 'severity' => 3, 'method' => 0, 'message' => 'error', + ]]); + + expect($saved)->toBe([]); +}); + +it('imports an alert whose query runs', function () { + $saved = alert_import_run([[ + 'name' => 'Fine', 'severity' => 3, 'method' => 0, 'type' => 'messagec', 'message' => 'error', + ]]); + + expect($saved)->toHaveCount(1) + ->and($saved[0]['name'])->toBe('Fine'); +}); + +it('rejects an imported alert with an unknown match type', function () { + $saved = alert_import_run([[ + 'name' => 'Unknown', 'severity' => 3, 'method' => 0, 'type' => 'nonsense', 'message' => 'error', + ]]); + + expect($saved)->toBe([]); +}); diff --git a/tests/Unit/AlertQueryIsolationTest.php b/tests/Unit/AlertQueryIsolationTest.php new file mode 100644 index 00000000..7e4b417e --- /dev/null +++ b/tests/Unit/AlertQueryIsolationTest.php @@ -0,0 +1,84 @@ + 'Error']; + $GLOBALS['syslog_levels'] = [3 => 'Error']; + $GLOBALS['syslog_facilities'] = [1 => 'User']; + $GLOBALS['syslog_incoming_config'] = [ + 'textField' => 'message', 'hostField' => 'host', 'programField' => 'program', + 'facilityField' => 'facility_id', 'priorityField' => 'priority_id', + ]; +} + + +function isolation_rule(int $id, string $message, int $level = 0): array { + return [ + 'id' => $id, 'name' => 'Rule ' . $id, 'type' => 'sql', 'message' => $message, + 'method' => 1, 'num' => 1, 'level' => $level, 'threshold_window' => 0, + 'maintenance_mode' => 'disabled', 'email' => '', 'notify' => 0, + 'repeat_alert' => 0, 'command' => '', 'severity' => 3, 'body' => '', + 'ticket' => '', 'open_ticket' => '', 'date' => 1, + ]; +} + + +it('still evaluates the other rules when the combined count query fails', function () { + isolation_setup(); + + $good = isolation_rule(33, "message LIKE 'good'"); + $bad = isolation_rule(34, "message LIKE 'bad' AND ("); + $none = ['type' => 'unknown'] + isolation_rule(36, 'unused'); + + test_override('syslog_db_fetch_assoc', static fn($sql) => str_contains($sql, "enabled = 'on'") ? [$good, $bad, $none] : []); + test_override('syslog_db_fetch_assoc_prepared', function ($sql) { + if (str_contains($sql, 'SUM(CASE WHEN')) { + return false; + } + + if (str_contains($sql, 'threshold_recovery_queue')) { + return []; + } + + return [['seq' => 1, 'host' => 'router1', 'message' => 'good', 'logtime' => '2026-10-16 12:00:00', 'priority_id' => 3, 'facility_id' => 1]]; + }); + test_override('syslog_db_fetch_cell_prepared', function ($sql) { + if (str_contains($sql, 'GET_LOCK')) { + return 1; + } + + if (str_contains($sql, 'SELECT COUNT(*)')) { + return str_contains($sql, "'bad'") ? false : 4; + } + + return 0; + }); + test_override('syslog_db_fetch_row_prepared', static fn() => []); + test_override('syslog_db_execute', static fn() => true); + test_override('syslog_db_execute_prepared', static fn() => true); + test_override('sql_save', static fn() => 1); + + $result = syslog_process_alerts(100); + + expect($result['syslog_alerts'])->toBe(3) + ->and($result['syslog_alarms'])->toBe(1); +}); diff --git a/tests/Unit/AlertReportRowLimitTest.php b/tests/Unit/AlertReportRowLimitTest.php new file mode 100644 index 00000000..18c21266 --- /dev/null +++ b/tests/Unit/AlertReportRowLimitTest.php @@ -0,0 +1,75 @@ + 'Error']; + $GLOBALS['syslog_levels'] = [3 => 'Error']; + $GLOBALS['syslog_facilities'] = [1 => 'User']; + $GLOBALS['syslog_incoming_config'] = [ + 'textField' => 'message', 'hostField' => 'host', 'programField' => 'program', + 'facilityField' => 'facility_id', 'priorityField' => 'priority_id', + ]; +} + + +function row_limit_rule(int $id, string $message, int $level = 0): array { + return [ + 'id' => $id, 'name' => 'Rule ' . $id, 'type' => 'sql', 'message' => $message, + 'method' => 1, 'num' => 1, 'level' => $level, 'threshold_window' => 0, + 'maintenance_mode' => 'disabled', 'email' => '', 'notify' => 0, + 'repeat_alert' => 0, 'command' => '', 'severity' => 3, 'body' => '', + 'ticket' => '', 'open_ticket' => '', 'date' => 1, + ]; +} + + +it('limits threshold alert detail rows to the configured maximum', function () { + row_limit_setup(); + + test_override('read_config_option', static fn($key) => ['syslog_html' => 'on', 'syslog_maxrecords' => 2][$key] ?? ''); + + $rows = []; + + foreach (['hosta', 'hostb', 'hostc', 'hostd'] as $host) { + $rows[] = ['seq' => 1, 'host' => $host, 'message' => 'error', 'logtime' => '2026-01-01 00:00:00', 'priority_id' => 3, 'facility_id' => 1]; + } + + test_override('syslog_db_fetch_assoc_prepared', static fn() => $rows); + + $saved = []; + + test_override('sql_save', function ($row) use (&$saved) { + $saved[] = $row; + + return 1; + }); + + $rule = row_limit_rule(35, 'error'); + + $rule['num'] = 2; + + expect(syslog_process_alert($rule, 'SELECT * FROM syslog_incoming', [], 4))->toBe(1); + + expect($saved[0]['html'])->toContain('hosta', 'hostb') + ->not->toContain('hostc') + ->not->toContain('hostd'); +}); diff --git a/tests/Unit/AlertThresholdWindowTest.php b/tests/Unit/AlertThresholdWindowTest.php index 0410bbb8..ad2980ce 100644 --- a/tests/Unit/AlertThresholdWindowTest.php +++ b/tests/Unit/AlertThresholdWindowTest.php @@ -13,6 +13,10 @@ return true; }); test_override('syslog_db_fetch_cell_prepared', function ($sql, $params) { + if (count($params) !== 5) { + return ''; + } + [$id, $hash, $scope, $cutoff, $now] = $params; return array_sum(array_map(static fn($b) => $b[4], array_filter($GLOBALS['threshold_buckets'], static fn($b) => $b[0] === $id && $b[1] === $hash && $b[2] === $scope && $b[3] > $cutoff && $b[3] <= $now))); }); diff --git a/tests/Unit/ArchiveRemovalJoinTest.php b/tests/Unit/ArchiveRemovalJoinTest.php new file mode 100644 index 00000000..130611ee --- /dev/null +++ b/tests/Unit/ArchiveRemovalJoinTest.php @@ -0,0 +1,60 @@ + 'message', 'hostField' => 'host', 'programField' => 'program', + 'facilityField' => 'facility_id', 'priorityField' => 'priority_id', + ]; + + test_override('syslog_db_fetch_cell_prepared', static fn() => 0); + test_override('syslog_status_set', static fn() => true); + test_override('syslog_status_increment', static fn() => true); +} + + +it('filters staging rows before joining the reference tables for archiving rules', function ($type) { + archive_join_setup(); + + $rule = ['id' => 5, 'name' => 'Archive', 'type' => $type, 'message' => 'router1', 'method' => 'trans', 'enabled' => 'on']; + + test_override('syslog_db_fetch_assoc', static fn($sql) => str_contains($sql, '`syslog_remove`') ? [$rule] : []); + + $executed = []; + + test_override('syslog_db_execute', static fn() => true); + test_override('syslog_db_execute_prepared', function ($sql, $params = []) use (&$executed) { + $executed[] = [$sql, $params]; + + return true; + }); + test_override('db_affected_rows', static fn() => 1); + + syslog_remove_items('syslog_incoming', 100); + + $insert = array_values(array_filter($executed, static fn($call) => str_contains($call[0], 'INSERT INTO `syslogdb`.`syslog_removed`'))); + $column = $type === 'host' ? 'host' : 'program'; + + expect($insert)->toHaveCount(1) + ->and($insert[0][0])->toContain("FROM (SELECT * FROM `syslogdb`.`syslog_incoming` WHERE `$column` = ?") + ->and($insert[0][0])->not->toMatch('/AS sp\s+ON sp\.program = si\.program\s+WHERE/') + ->and($insert[0][1])->toBe(['router1', 100]); +})->with(['host', 'program']); diff --git a/tests/Unit/DashboardCombinedQueryTest.php b/tests/Unit/DashboardCombinedQueryTest.php new file mode 100644 index 00000000..faec0df3 --- /dev/null +++ b/tests/Unit/DashboardCombinedQueryTest.php @@ -0,0 +1,28 @@ +toBe(3) + ->and(substr_count($breakdown, 'GROUP BY label'))->toBe(3); +}); diff --git a/tests/Unit/HostAddressResolutionTest.php b/tests/Unit/HostAddressResolutionTest.php new file mode 100644 index 00000000..c493e0bf --- /dev/null +++ b/tests/Unit/HostAddressResolutionTest.php @@ -0,0 +1,243 @@ +toBe($expected); +})->with([ + 'ipv4' => ['203.0.113.5', '203.0.113.5'], + 'ipv6 compressed' => ['2001:db8::1', '2001:db8::1'], + 'ipv6 expanded' => ['2001:0db8:0000:0000:0000:0000:0000:0001', '2001:db8::1'], + 'ipv6 mixed case' => ['2001:DB8::AbC', '2001:db8::abc'], + 'ipv6 brackets' => ['[2001:db8::0001]', '2001:db8::1'], + 'ipv6 mapped ipv4' => ['::FFFF:203.0.113.5', '::ffff:203.0.113.5'], + 'ipv6 zone' => ['FE80::1%eth0', 'fe80::1%eth0'], + 'ipv6 unspecified' => ['0:0:0:0:0:0:0:0', '::'], + 'name' => ['router1.example.net', false], + 'name with digits' => ['203.0.113.5.example.net', false], + 'out of range ipv4' => ['256.1.1.1', false], + 'bad ipv6' => ['2001:db8::g', false], + 'ipv4 with zone' => ['203.0.113.5%eth0', false], + 'empty' => ['', false], + 'several brackets' => ['[[2001:db8::1]]', false], +]); + +it('counts an A record, an AAAA record or both as resolving', function ($records, $legacy, $expected) { + $answers = static fn($name, $types) => $records; + $old = static fn($name) => $legacy ?? $name; + + expect(syslog_host_has_address('sender.example.net', $answers, $old))->toBe($expected); +})->with([ + 'A only' => [[['type' => 'A', 'ip' => '203.0.113.5']], null, true], + 'AAAA only' => [[['type' => 'AAAA', 'ipv6' => '2001:db8::1']], null, true], + 'both' => [[['type' => 'A', 'ip' => '203.0.113.5'], ['type' => 'AAAA', 'ipv6' => '2001:db8::1']], null, true], + 'none' => [[], null, false], + 'failed query' => [false, null, false], + 'hosts file entry only' => [[], '203.0.113.9', true], + 'failed query, hosts file' => [false, '203.0.113.9', true], +]); + +it('asks for both record types and keeps a failed query out of the log', function () { + $asked = []; + + $records = function ($name, $types) use (&$asked) { + $asked[] = [$name, $types]; + trigger_error('DNS Query failed', E_USER_WARNING); + + return false; + }; + + expect(syslog_host_has_address('missing.example.net', $records, static fn($name) => $name))->toBeFalse() + ->and($asked)->toBe([['missing.example.net', DNS_A | DNS_AAAA]]); +}); + +it('resolves a name from the hosts file with the system resolver', function () { + expect(syslog_host_has_address('localhost'))->toBeTrue(); +}); + +function resolve_hosts_setup(array $hosts, array $options = [], array $devices = []): stdClass { + $GLOBALS['syslogdb_default'] = 'syslogdb'; + + $state = new stdClass(); + $state->updates = []; + $state->logs = []; + + test_override('read_config_option', static fn($key) => ($options + ['syslog_resolve_hostname' => 'on', 'syslog_no_dns' => ''])[$key] ?? ''); + test_override('syslog_db_fetch_assoc_prepared', static fn() => $hosts); + test_override('db_fetch_row_prepared', static fn($sql, $params) => isset($devices[$params[0]]) ? ['description' => $devices[$params[0]]] : []); + test_override('syslog_db_execute_prepared', function ($sql, $params) use ($state) { + $state->updates[] = $params; + + return true; + }); + test_override('cacti_log', function ($message) use ($state) { + $state->logs[] = $message; + }); + + return $state; +} + +it('treats addresses as resolved without a lookup and leaves a canonical one alone', function ($name) { + $state = resolve_hosts_setup([['host' => $name]]); + + expect(syslog_resolve_incoming_hosts(100))->toBe(1) + ->and($state->updates)->toBe([]) + ->and($state->logs)->toBe([]); +})->with(['203.0.113.5', '2001:db8::1', '::1']); + +it('stores one canonical spelling for an IPv6 sender', function ($name) { + $state = resolve_hosts_setup([['host' => $name]]); + + expect(syslog_resolve_incoming_hosts(100))->toBe(1) + ->and($state->updates)->toBe([['2001:db8::1', $name, 100]]); +})->with(['2001:DB8::1', '2001:0db8:0000:0000:0000:0000:0000:0001', '[2001:db8::1]']); + +it('limits the canonical rename to a slice of messages', function () { + $state = resolve_hosts_setup([['host' => '2001:DB8::1']]); + + syslog_resolve_incoming_hosts(100, 10, 20); + + expect($state->updates)->toBe([['2001:db8::1', '2001:DB8::1', 10, 20]]); +}); + +it('names a device found in Cacti by the spelling it uses', function ($sent, $stored, $renames) { + $state = resolve_hosts_setup([['host' => $sent]], [], [$stored => 'Core Router']); + + expect(syslog_resolve_incoming_hosts(100))->toBe(1) + ->and($state->updates)->toBe($renames); +})->with([ + 'ipv4' => ['203.0.113.5', '203.0.113.5', [['Core Router', '203.0.113.5', 100]]], + 'ipv6 as sent' => ['2001:DB8::1', '2001:DB8::1', [['Core Router', '2001:DB8::1', 100]]], + 'ipv6 stored in canonical form' => ['2001:DB8::1', '2001:db8::1', [['2001:db8::1', '2001:DB8::1', 100], ['Core Router', '2001:db8::1', 100]]], +]); + +it('marks a name that does not resolve and is not a device', function () { + $state = resolve_hosts_setup([['host' => 'no-such-sender.invalid']]); + + expect(syslog_resolve_incoming_hosts(100))->toBe(0) + ->and($state->updates)->toBe([['unresolved-no-such-sender.invalid', 'no-such-sender.invalid', 100]]) + ->and($state->logs)->toHaveCount(1); +}); + +it('keeps a name that resolves', function () { + $state = resolve_hosts_setup([['host' => 'localhost']]); + + expect(syslog_resolve_incoming_hosts(100))->toBe(1) + ->and($state->updates)->toBe([]); +}); + +it('skips the lookup when DNS is turned off but still resolves addresses and devices', function () { + $state = resolve_hosts_setup( + [['host' => 'localhost'], ['host' => '2001:DB8::2'], ['host' => 'known.example.net']], + ['syslog_no_dns' => 'on'], + ['known.example.net' => 'Known Device'] + ); + + expect(syslog_resolve_incoming_hosts(100))->toBe(2) + ->and($state->updates)->toBe([ + ['unresolved-localhost', 'localhost', 100], + ['2001:db8::2', '2001:DB8::2', 100], + ['Known Device', 'known.example.net', 100], + ]); +}); + +it('does nothing when name checking is off or the host is empty', function () { + $state = resolve_hosts_setup([['host' => '']], ['syslog_resolve_hostname' => '']); + + expect(syslog_resolve_incoming_hosts(100))->toBe(0) + ->and($state->updates)->toBe([]); + + $state = resolve_hosts_setup([['host' => ''], ['other' => 'x']]); + + expect(syslog_resolve_incoming_hosts(100))->toBe(0) + ->and($state->updates)->toBe([]); +}); + +it('limits a marked name to a slice of messages', function () { + $state = resolve_hosts_setup([['host' => 'no-such-sender.invalid']]); + + syslog_resolve_incoming_hosts(100, 10, 20); + + expect($state->updates)->toBe([['unresolved-no-such-sender.invalid', 'no-such-sender.invalid', 10, 20]]); +}); + +function aaaa_only_answers(array &$asked): callable { + return function ($name, $types) use (&$asked) { + $asked[] = $name; + + return [['type' => 'AAAA', 'ipv6' => '2001:db8::53']]; + }; +} + +it('counts a name with only an AAAA record as resolved within the budget', function () { + $asked = []; + $state = resolve_hosts_setup([['host' => 'v6-one.example.net'], ['host' => 'v6-two.example.net']]); + + expect(syslog_resolve_incoming_hosts(100, 0, 0, 60, aaaa_only_answers($asked)))->toBe(2) + ->and($asked)->toBe(['v6-one.example.net', 'v6-two.example.net']) + ->and($state->updates)->toBe([]) + ->and($state->logs)->toBe([]); +}); + +it('stops looking up AAAA-only names once the budget is spent and leaves them as received', function () { + $asked = []; + $state = resolve_hosts_setup([['host' => 'v6-one.example.net'], ['host' => 'v6-two.example.net']]); + + expect(syslog_resolve_incoming_hosts(100, 0, 0, 0, aaaa_only_answers($asked)))->toBe(0) + ->and($asked)->toBe([]) + ->and($state->updates)->toBe([]) + ->and($state->logs)->toHaveCount(1) + ->and($state->logs[0])->toContain('budget'); +}); + +it('does not charge address literals to the budget', function () { + $asked = []; + $state = resolve_hosts_setup([['host' => '203.0.113.5'], ['host' => '2001:DB8::7']]); + + expect(syslog_resolve_incoming_hosts(100, 0, 0, 0, aaaa_only_answers($asked)))->toBe(2) + ->and($asked)->toBe([]) + ->and($state->updates)->toBe([['2001:db8::7', '2001:DB8::7', 100]]) + ->and($state->logs)->toBe([]); +}); + +it('resolves literals and devices after the budget is spent and reports the skipped name once', function () { + $asked = []; + $state = resolve_hosts_setup( + [['host' => 'v6-one.example.net'], ['host' => '2001:DB8::7'], ['host' => 'known.example.net'], ['host' => 'v6-two.example.net']], + [], + ['known.example.net' => 'Known Device'] + ); + + expect(syslog_resolve_incoming_hosts(100, 0, 0, 0, aaaa_only_answers($asked)))->toBe(2) + ->and($asked)->toBe([]) + ->and($state->updates)->toBe([['2001:db8::7', '2001:DB8::7', 100], ['Known Device', 'known.example.net', 100]]) + ->and($state->logs)->toHaveCount(1); +}); + +it('never asks DNS when lookups are disabled, whatever the budget', function ($budget) { + $asked = []; + $state = resolve_hosts_setup([['host' => 'v6-one.example.net']], ['syslog_no_dns' => 'on']); + + expect(syslog_resolve_incoming_hosts(100, 0, 0, $budget, aaaa_only_answers($asked)))->toBe(0) + ->and($asked)->toBe([]) + ->and($state->updates)->toBe([['unresolved-v6-one.example.net', 'v6-one.example.net', 100]]) + ->and(array_filter($state->logs, static fn($line) => str_contains($line, 'budget')))->toBe([]); +})->with([0, 60]); diff --git a/tests/Unit/HostnameLookupBudgetTest.php b/tests/Unit/HostnameLookupBudgetTest.php new file mode 100644 index 00000000..d6a4a471 --- /dev/null +++ b/tests/Unit/HostnameLookupBudgetTest.php @@ -0,0 +1,78 @@ +updates = []; + $state->logs = []; + + test_override('read_config_option', static fn($key) => ['syslog_resolve_hostname' => 'on', 'syslog_no_dns' => ''][$key] ?? ''); + test_override('syslog_db_fetch_assoc_prepared', static fn() => $hosts); + test_override('db_fetch_row_prepared', static fn() => []); + test_override('syslog_db_execute_prepared', function ($sql, $params) use ($state) { + $state->updates[] = $params; + + return true; + }); + test_override('cacti_log', function ($message) use ($state) { + $state->logs[] = $message; + }); + + return $state; +} + + +it('leaves hostnames as received once the lookup budget is spent', function () { + $state = resolve_setup([['host' => 'router-one'], ['host' => 'router-two']]); + + expect(syslog_resolve_incoming_hosts(100, 0, 0, 0))->toBe(0) + ->and($state->updates)->toBe([]) + ->and($state->logs)->toHaveCount(1) + ->and($state->logs[0])->toContain('budget'); +}); + + +it('resolves a hostname within the lookup budget', function () { + $state = resolve_setup([['host' => 'localhost']]); + + expect(syslog_resolve_incoming_hosts(100))->toBe(1) + ->and($state->updates)->toBe([]) + ->and($state->logs)->toBe([]); +}); + + +it('still marks a name unresolved when the lookup was made and failed', function () { + $state = resolve_setup([['host' => 'no-such-sender.invalid']]); + + expect(syslog_resolve_incoming_hosts(100))->toBe(0) + ->and($state->updates)->toBe([['unresolved-no-such-sender.invalid', 'no-such-sender.invalid', 100]]); +}); + + +it('resolves a name from the Cacti device list even when lookups were skipped', function () { + $state = resolve_setup([['host' => 'router-one']]); + test_override('db_fetch_row_prepared', static fn() => ['description' => 'Router One']); + + expect(syslog_resolve_incoming_hosts(100, 0, 0, 0))->toBe(1) + ->and($state->updates)->toBe([['Router One', 'router-one', 100]]); +}); diff --git a/tests/Unit/OwnerColumnWidthTest.php b/tests/Unit/OwnerColumnWidthTest.php new file mode 100644 index 00000000..a7b7a91e --- /dev/null +++ b/tests/Unit/OwnerColumnWidthTest.php @@ -0,0 +1,97 @@ +alters = []; + $state->status = []; + + test_override('syslog_db_table_exists', static fn() => true); + test_override('syslog_db_column_exists', static fn() => true); + test_override('syslog_db_add_column', static fn() => true); + test_override('db_update_table', static fn() => true); + test_override('syslog_db_fetch_cell_prepared', static fn($sql, $params) => ($params[0] ?? '') === 'schema_user_width' ? $marker : ''); + test_override('syslog_db_execute', function ($sql) use ($state, $execute) { + $state->alters[] = $sql; + + return $execute ? $execute($sql) : true; + }); + test_override('syslog_db_execute_prepared', function ($sql, $params) use ($state) { + if (str_contains($sql, 'syslog_status') && ($params[0] ?? '') === 'schema_user_width') { + $state->status[] = $params[1]; + } + + return true; + }); + + return $state; +} + + +it('widens the owner column of every rule table once', function () { + $state = owner_width_setup(null); + + expect(syslog_ensure_table_structures())->toBeTrue(); + + $alters = array_values(array_filter($state->alters, static fn($sql) => str_starts_with($sql, 'ALTER TABLE') && str_contains($sql, '`user` varchar(50)'))); + + expect($alters)->toHaveCount(4) + ->and($alters[0])->toContain('`syslogdb`.`syslog_alert`') + ->and($alters[3])->toContain('`syslogdb`.`syslog_device_rule`') + ->and($state->status)->toBe(['50']); +}); + + +it('does not repeat the owner column change after it succeeded', function () { + $state = owner_width_setup('50'); + + expect(syslog_ensure_table_structures())->toBeTrue() + ->and(array_filter($state->alters, static fn($sql) => str_starts_with($sql, 'ALTER TABLE') && str_contains($sql, '`user` varchar(50)')))->toBe([]) + ->and($state->status)->toBe([]); +}); + + +it('does not record the owner column change when a table cannot be altered', function () { + $state = owner_width_setup(null, static fn($sql) => !str_contains($sql, 'syslog_reports')); + + expect(syslog_ensure_table_structures())->toBeFalse() + ->and($state->status)->toBe([]); +}); + + +it('does not record the owner column change when the marker cannot be stored', function () { + owner_width_setup(null); + test_override('syslog_db_execute_prepared', static fn() => false); + + expect(syslog_ensure_table_structures())->toBeFalse(); +}); + + +it('creates the owner columns at the Cacti username width', function () { + $schema = plugin_test_read_source('includes/schema.php'); + + expect($schema)->not->toContain('`user` varchar(32)') + ->and($schema)->not->toContain("'name' => 'user', 'type' => 'varchar(32)'"); +}); diff --git a/tests/Unit/RegressionScriptInertTest.php b/tests/Unit/RegressionScriptInertTest.php new file mode 100644 index 00000000..bad64b54 --- /dev/null +++ b/tests/Unit/RegressionScriptInertTest.php @@ -0,0 +1,90 @@ + !str_starts_with($name, 'SYSLOG_TEST_') && !str_starts_with($name, 'SYSLOG_COVERAGE_'), ARRAY_FILTER_USE_KEY); +} + +function inert_run_cli(string $script, array $arguments = []): array { + $run = proc_open(array_merge([PHP_BINARY, $script], $arguments), [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, inert_environment()); + + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + + fclose($pipes[1]); + fclose($pipes[2]); + + return [proc_close($run), $stdout, $stderr]; +} + +it('does nothing without the test environment', function ($script, $status, $message) { + [$code, $stdout, $stderr] = inert_run_cli(dirname(__DIR__) . '/' . $script); + + expect($code)->toBe($status) + ->and($stdout)->toBe('') + ->and($stderr)->toContain($message); +})->with([ + 'database checks' => ['regression/review_fixes_database_test.php', 2, 'disposable test server'], + 'database path checks' => ['regression/review_fixes_paths_database_test.php', 2, 'disposable test server'], + 'live pipeline checks' => ['regression/live_pipeline_test.php', 2, 'SYSLOG_TEST_LIVE=1'], + 'coverage report' => ['bin/coverage-report.php', 2, 'usage'], +]); + +it('stays silent as a prepend file without coverage settings', function () { + [$code, $stdout, $stderr] = inert_run_cli(dirname(__DIR__) . '/bin/coverage-prepend.php'); + + // Started directly it answers like a web request would: nothing, with a 404 status. + expect($stdout)->toBe('')->and($stderr)->toBe(''); +}); + +it('answers 404 with an empty body when requested over the web', function ($directory, $file) { + $port = random_int(20000, 60000); + $server = proc_open([PHP_BINARY, '-S', '127.0.0.1:' . $port, '-t', dirname(__DIR__) . '/' . $directory], [1 => ['file', '/dev/null', 'w'], 2 => ['file', '/dev/null', 'w']], $pipes, null, inert_environment()); + $status = 0; + $body = null; + + try { + for ($attempt = 0; $attempt < 50 && $body === null; $attempt++) { + set_error_handler(static fn() => true); + $socket = fsockopen('127.0.0.1', $port, $errno, $errstr, 0.2); + restore_error_handler(); + + if ($socket === false) { + usleep(100000); + + continue; + } + + fwrite($socket, "GET /$file HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n"); + $response = stream_get_contents($socket); + fclose($socket); + + [$head, $body] = array_pad(explode("\r\n\r\n", $response, 2), 2, ''); + $status = (int) substr($head, 9, 3); + } + } finally { + proc_terminate($server); + proc_close($server); + } + + expect($status)->toBe(404) + ->and($body)->toBe(''); +})->with([ + 'database checks' => ['regression', 'review_fixes_database_test.php'], + 'database path checks' => ['regression', 'review_fixes_paths_database_test.php'], + 'live pipeline checks' => ['regression', 'live_pipeline_test.php'], + 'shared setup' => ['regression/support', 'database_shim.php'], + 'coverage report' => ['bin', 'coverage-report.php'], + 'coverage prepend' => ['bin', 'coverage-prepend.php'], +]); diff --git a/tests/Unit/RemovalReprocessTest.php b/tests/Unit/RemovalReprocessTest.php new file mode 100644 index 00000000..1a3722c5 --- /dev/null +++ b/tests/Unit/RemovalReprocessTest.php @@ -0,0 +1,71 @@ + 'message', 'hostField' => 'host', 'programField' => 'program', + 'facilityField' => 'facility_id', 'priorityField' => 'priority_id', + ]; + + test_override('syslog_db_fetch_cell_prepared', static fn() => 0); + test_override('syslog_status_set', static fn() => true); + test_override('syslog_status_increment', static fn() => true); +} + + +it('reprocesses only the selected removal rule', function () { + reprocess_setup(); + + $rule = ['id' => 5, 'name' => 'Only', 'type' => 'messagec', 'message' => 'noise', 'method' => 'del', 'enabled' => 'on']; + $asked = []; + + test_override('syslog_db_fetch_assoc_prepared', function ($sql, $params) use (&$asked, $rule) { + $asked[] = [$sql, $params]; + + return [$rule]; + }); + + $unfiltered = []; + + test_override('syslog_db_fetch_assoc', function ($sql) use (&$unfiltered) { + $unfiltered[] = $sql; + + return []; + }); + + $deleted = []; + + test_override('syslog_db_execute_prepared', function ($sql, $params = []) use (&$deleted) { + if (str_starts_with($sql, 'DELETE FROM')) { + $deleted[] = $params; + } + + return true; + }); + test_override('db_affected_rows', static fn() => 3); + + expect(syslog_remove_items('syslog', 0, 5))->toBe(['removed' => 3, 'xferred' => 0]); + expect($asked)->toHaveCount(1) + ->and($asked[0][0])->toContain('AND id = ?') + ->and($asked[0][1])->toBe([5]) + ->and($deleted)->toBe([['%noise%']]) + ->and(array_filter($unfiltered, static fn($sql) => str_contains($sql, '`syslog_remove`')))->toBe([]); +}); diff --git a/tests/Unit/RemovalRuleTransactionTest.php b/tests/Unit/RemovalRuleTransactionTest.php index 3ea1690d..499ac937 100644 --- a/tests/Unit/RemovalRuleTransactionTest.php +++ b/tests/Unit/RemovalRuleTransactionTest.php @@ -15,7 +15,7 @@ it('wraps syslog_remove_items archive and delete operations in a transaction', function () { $functions = plugin_test_read_source('includes/functions.php'); - if (!preg_match('/function\s+syslog_remove_items\s*\(\s*\$table\s*,\s*\$max_seq\s*\)\s*\{(.+?)\nfunction\s+syslog_log_row_color/s', $functions, $match)) { + if (!preg_match('/function\s+syslog_remove_items\s*\(\s*\$table\s*,\s*\$max_seq\s*,\s*\$rule_id\s*=\s*0\s*\)\s*\{(.+?)\nfunction\s+syslog_log_row_color/s', $functions, $match)) { throw new RuntimeException('syslog_remove_items function not found.'); } diff --git a/tests/Unit/SavedSearchImportTest.php b/tests/Unit/SavedSearchImportTest.php new file mode 100644 index 00000000..f8b40dfc --- /dev/null +++ b/tests/Unit/SavedSearchImportTest.php @@ -0,0 +1,57 @@ + 1, 'table' => 'syslog_saved_searches', 'templates' => $templates]); + $_SESSION['sess_user_id'] = 1; + + test_override('syslog_db_fetch_cell_prepared', static fn() => false); + test_override('syslog_db_column_exists', static fn() => true); + test_override('syslog_save_item_shares', static fn() => null); + + $saved = []; + + test_override('sql_save', function ($row) use (&$saved) { + $saved[] = $row; + + return 3; + }); + + $import(); + + return $saved; +} + +it('imports a saved search template that passes the editor checks', function () { + $saved = saved_search_import_run([['name' => 'Errors', 'search' => 'message contains "error"', 'removal' => 1, 'grouping' => 0]]); + + expect($saved)->toHaveCount(1) + ->and($saved[0]['name'])->toBe('Errors'); +}); + +it('rejects a saved search template whose search does not parse', function () { + expect(saved_search_import_run([['name' => 'Broken', 'search' => 'host = "unterminated']]))->toBe([]); +}); + +it('rejects a saved search template with an unknown record type or grouping', function ($removal, $grouping) { + expect(saved_search_import_run([['name' => 'Odd', 'search' => 'message contains "x"', 'removal' => $removal, 'grouping' => $grouping]]))->toBe([]); +})->with([['abc', 0], [1, 7]]); diff --git a/tests/Unit/ThresholdRecoveryCommandTest.php b/tests/Unit/ThresholdRecoveryCommandTest.php index dcc67853..48670ad3 100644 --- a/tests/Unit/ThresholdRecoveryCommandTest.php +++ b/tests/Unit/ThresholdRecoveryCommandTest.php @@ -113,11 +113,14 @@ function threshold_command_context(string $command): array { })->with([true, false]); it('repairs recovery storage without replacing historical message tables', function ($failed_table) { + syslog_load_plugin_source('includes/functions.php'); syslog_load_plugin_source('includes/schema.php'); test_override('syslog_db_table_exists', static fn() => true); // the digest migration hooks run inside the same repair pass test_override('syslog_db_column_exists', static fn() => true); test_override('syslog_db_add_column', static fn($table, $column) => !($table === $failed_table && $column['name'] === 'recovery_context')); + test_override('syslog_db_execute', static fn() => true); + test_override('syslog_db_execute_prepared', static fn() => true); expect(syslog_ensure_table_structures())->toBe($failed_table === 'none'); })->with(['syslog_threshold_state', 'none']); diff --git a/tests/Unit/UpgradeAdvisorTextTest.php b/tests/Unit/UpgradeAdvisorTextTest.php new file mode 100644 index 00000000..7c2f9511 --- /dev/null +++ b/tests/Unit/UpgradeAdvisorTextTest.php @@ -0,0 +1,57 @@ + "30 days"]; } + function syslog_connect() { return true; } + function syslog_db_fetch_row($sql) { return ["Value" => "10.11.0-MariaDB"]; } + function syslog_remote_collector_requires_innodb() { return false; } + function read_config_option($name) { return ""; } + function inject_form_variables($fields, $args) { return $fields; } + function html_start_box($title, ...$args) { print "BOX " . $title . "\n"; } + function html_end_box() {} + function draw_edit_form($form) { foreach ($form["fields"] as $name => $field) { print "FIELD " . $name . ": " . ($field["description"] ?? "") . "\n"; } } + require ' . var_export($installer, true) . '; + syslog_install_advisor(' . $exists . '); + '); + + try { + exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($script) . ' 2>&1', $output); + + return implode("\n", $output); + } finally { + unlink($script); + } +} + +it('tells an upgrade that truncate keeps the rules', function () { + $output = upgrade_advisor_output(1); + + expect($output)->toContain('FIELD upgrade_type:') + ->toContain('A Truncate discards the stored syslog and removed messages only; alert, removal and report rules are always kept.') + ->toContain('all existing removal and alert rules will be maintained'); +}); + +it('offers no upgrade choice for a new install', function () { + $output = upgrade_advisor_output(0); + + expect($output)->not->toContain('FIELD upgrade_type:') + ->toContain('FIELD engine:'); +}); diff --git a/tests/Unit/UpgradeTruncateTest.php b/tests/Unit/UpgradeTruncateTest.php new file mode 100644 index 00000000..96bc6b94 --- /dev/null +++ b/tests/Unit/UpgradeTruncateTest.php @@ -0,0 +1,65 @@ + 1]; + $syslogdb_default = "syslogdb"; + $syslog_cnn = new stdClass(); + $settings = ["syslog" => []]; + $statements = []; + function syslog_connect() { return true; } + function syslog_db_execute($sql, $log = true) { $GLOBALS["statements"][] = $sql; return true; } + function syslog_db_fetch_row($sql, $log = true) { return []; } + function syslog_db_fetch_cell_prepared($sql, $params = [], $col = "", $log = true) { return 1; } + function syslog_db_table_exists($table, $log = true) { return true; } + function syslog_install_storage_engine($engine, $options = []) { return "InnoDB"; } + function syslog_dashboard_seed_default() {} + function syslog_validate_storage_engine($engine) { return "InnoDB"; } + function read_config_option($name) { return ""; } + function set_config_option($name, $value, $force = false) {} + function db_update_table(...$args) { return true; } + function db_fetch_cell_prepared(...$args) { return 1; } + function cacti_sizeof($value) { return is_array($value) ? count($value) : 0; } + function cacti_log(...$args) {} + function __($text, ...$args) { return $text; } + require ' . var_export($schema, true) . '; + syslog_setup_table_new(["upgrade_type" => "truncate", "engine" => "InnoDB", "days" => 30], false); + $dropped = []; + foreach ($statements as $sql) { + if (preg_match("/^DROP TABLE IF EXISTS `syslogdb`\.`(\w+)`/", $sql, $m)) { + $dropped[] = $m[1]; + } + } + echo json_encode($dropped); + '); + + try { + exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($script) . ' 2>&1', $output, $status); + + $dropped = json_decode(implode("\n", $output), true); + + expect($status)->toBe(0) + ->and($dropped)->toBeArray() + ->and($dropped)->toContain('syslog') + ->and($dropped)->not->toContain('syslog_alert') + ->and($dropped)->not->toContain('syslog_remove') + ->and($dropped)->not->toContain('syslog_reports'); + } finally { + unlink($script); + } +}); diff --git a/tests/bin/coverage-prepend.php b/tests/bin/coverage-prepend.php new file mode 100644 index 00000000..6a84447c --- /dev/null +++ b/tests/bin/coverage-prepend.php @@ -0,0 +1,50 @@ +/.jsonl (one plugin file per line), and only for plugin + * files outside tests/. Merge the dumps with tests/bin/coverage-report.php. + */ +$syslog_cov_dir = getenv('SYSLOG_COVERAGE_DIR') ?: get_cfg_var('syslog.coverage_dir'); +$syslog_cov_root = getenv('SYSLOG_COVERAGE_ROOT') ?: get_cfg_var('syslog.coverage_root'); + +if (isset($_SERVER['SCRIPT_FILENAME']) && realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) { + http_response_code(404); + + exit; +} + +if ($syslog_cov_dir === false || $syslog_cov_dir === '' || $syslog_cov_root === false || $syslog_cov_root === '' + || !function_exists('xdebug_start_code_coverage') || !is_dir($syslog_cov_dir)) { + return; +} + +xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE | XDEBUG_CC_BRANCH_CHECK); + +register_shutdown_function(static function () use ($syslog_cov_dir, $syslog_cov_root) { + $root = rtrim($syslog_cov_root, '/') . '/'; + $out = $syslog_cov_dir . '/' . hash('sha256', getmypid() . microtime(true)) . '.jsonl'; + + foreach (xdebug_get_code_coverage() as $file => $data) { + if (strpos($file, $root) !== 0 || strpos($file, $root . 'tests/') === 0) { + continue; + } + + file_put_contents($out, json_encode(['file' => substr($file, strlen($root)), 'data' => $data]) . "\n", FILE_APPEND); + } + + xdebug_stop_code_coverage(); +}); diff --git a/tests/bin/coverage-report.php b/tests/bin/coverage-report.php new file mode 100644 index 00000000..6c8cd3ab --- /dev/null +++ b/tests/bin/coverage-report.php @@ -0,0 +1,131 @@ + [--fail] + * + * Exits 1 with --fail when a changed executable line was not executed, 2 on + * bad input. + */ +if (PHP_SAPI !== 'cli') { + http_response_code(404); + + exit; +} + +if ($argc < 3 || !is_dir($argv[1])) { + fwrite(STDERR, "usage: coverage-report.php [--fail]\n"); + + exit(2); +} + +$diff = []; +exec('git --no-pager diff --no-ext-diff -U0 ' . escapeshellarg($argv[2]) . '...HEAD -- . ' . escapeshellarg(':!tests') . ' ' . escapeshellarg(':!*.md') . ' ' . escapeshellarg(':!locales') . ' ' . escapeshellarg(':!.github') . ' 2>&1', $diff); + +$file = ''; +$changed = []; + +foreach ($diff as $row) { + if (preg_match('#^\+\+\+ b/(.+\.php)$#', $row, $match)) { + $file = $match[1]; + } elseif ($file !== '' && preg_match('/^@@ -\S+ \+(\d+)(?:,(\d+))? @@/', $row, $match)) { + $count = isset($match[2]) ? (int) $match[2] : 1; + + for ($i = 0; $i < $count; $i++) { + $changed[$file][] = (int) $match[1] + $i; + } + } +} + +$lines = []; +$branches = []; + +foreach (array_merge(glob($argv[1] . '/*.json'), glob($argv[1] . '/*.jsonl')) as $dump) { + foreach (file($dump, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $row) { + $decoded = json_decode($row, true) ?: []; + $entries = isset($decoded['file'], $decoded['data']) ? [$decoded['file'] => $decoded['data']] : $decoded; + + foreach ($entries as $file => $data) { + if (!isset($changed[$file])) { + continue; + } + + foreach ($data['lines'] ?? [] as $number => $hit) { + $lines[$file][$number] = max($lines[$file][$number] ?? -2, $hit); + } + + foreach ($data['functions'] ?? [] as $function => $info) { + foreach ($info['branches'] ?? [] as $id => $branch) { + $key = $function . '#' . $id; + + if (!isset($branches[$file][$key])) { + $branches[$file][$key] = $branch + ['function' => $function]; + } + + $branches[$file][$key]['hit'] = max($branches[$file][$key]['hit'], $branch['hit']); + } + } + } + } +} + +$failed = false; + +printf("%-28s %8s %9s %9s %12s\n", 'file', 'changed', 'executed', 'missed', 'branches'); + +foreach ($changed as $name => $numbers) { + $executed = 0; + $missed = []; + + foreach ($numbers as $number) { + $hit = $lines[$name][$number] ?? null; + + if ($hit === 1) { + $executed++; + } elseif ($hit === -1) { + $missed[] = $number; + } + } + + $overlap = 0; + $taken = 0; + $unhit = []; + + foreach ($branches[$name] ?? [] as $branch) { + foreach ($numbers as $number) { + if ($number >= $branch['line_start'] && $number <= $branch['line_end']) { + $overlap++; + $taken += $branch['hit'] ? 1 : 0; + + if (!$branch['hit']) { + $unhit[$branch['line_start'] . '-' . $branch['line_end']] = true; + } + + break; + } + } + } + + printf("%-28s %8d %9d %9d %12s\n", $name, count($numbers), $executed, count($missed), $overlap ? $taken . '/' . $overlap : 'n/a'); + + if ($missed) { + print ' missed lines: ' . implode(', ', $missed) . "\n"; + $failed = true; + } + + if ($unhit) { + print ' branches not taken at lines: ' . implode(', ', array_keys($unhit)) . "\n"; + } +} + +exit($failed && in_array('--fail', $argv, true) ? 1 : 0); diff --git a/tests/bin/populate_syslog_incoming.sh b/tests/bin/populate_syslog_incoming.sh index e51878f6..2063308b 100644 --- a/tests/bin/populate_syslog_incoming.sh +++ b/tests/bin/populate_syslog_incoming.sh @@ -1,14 +1,21 @@ #!/bin/bash # filepath: insert_syslog_test_data.sh +# A failed insert must fail the CI step instead of leaving the run without data. +set -euo pipefail + # Number of iterations ITERATIONS=100 -SQL_ALERT_RULE_INSERT="INSERT INTO syslog_alert (id,hash,name,severity,method,level,num,type,enabled,repeat_alert,open_ticket,message,body,user,date,email,notify,command,notes) - VALUES (1,'8f440030d3425e37cb66e5df54902bb0','interface down alert',1,0,1,1,'messageb','on',0,'','interface down','admin',1767376990,NULL,0,NULL,NULL);" +# Every NOT NULL column without a default must be listed (message and +# suppression_schedule), or the insert fails under strict SQL mode. Empty +# strings are used for the text columns the processor reads, because a NULL +# there raises a PHP deprecation notice in the Cacti log. +SQL_ALERT_RULE_INSERT="REPLACE INTO syslog_alert (id,hash,name,severity,method,level,num,type,enabled,repeat_alert,open_ticket,message,body,user,date,email,notify,command,recovery_command,notes,suppression_schedule) + VALUES (1,'8f440030d3425e37cb66e5df54902bb0','interface down alert',1,0,1,1,'messageb','on',0,'','interface down','','admin',1767376990,'',0,'','','','');" -SQL_REMOVAL_RULE_INSERT="INSERT INTO syslog_remove (id,hash,name,type,enabled,method,message,user,date,notes) +SQL_REMOVAL_RULE_INSERT="REPLACE INTO syslog_remove (id,hash,name,type,enabled,method,message,user,date,notes) VALUES (1,'0faf589f7b6b7da1bcec40b92340487d','exploded','messageb','on','del', 'the box exploded','admin',1767376604,NULL);" diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index c6587b12..c95440aa 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -756,6 +756,12 @@ function syslog_sql_save($row, $table, $key = 'id', $autoinc = true) { } } +if (!function_exists('generate_hash')) { + function generate_hash() { + return bin2hex(random_bytes(16)); + } +} + if (!function_exists('cacti_escapeshellarg')) { function cacti_escapeshellarg($value) { return escapeshellarg((string) $value); @@ -785,6 +791,47 @@ function syslog_test_page_function(string $file, string $name): string { return $alias; } +/** + * Load one real function from a plugin file without running the file. + * + * The extracted source is written to a private temporary file, required once + * and removed at shutdown; nothing is evaluated from a string. + */ +function syslog_test_extract_function(string $file, string $name): string { + $alias = 'syslog_test_' . preg_replace('/\W/', '_', $file) . '_' . $name; + + if (function_exists($alias)) { + return $alias; + } + + $source = plugin_test_read_source($file); + + if (!preg_match('/^function ' . preg_quote($name, '/') . '\\(.*?^\\}/ms', $source, $match)) { + throw new RuntimeException("Unable to load $file::$name"); + } + + $directory = sys_get_temp_dir() . '/syslog-test-' . bin2hex(random_bytes(6)); + $path = $directory . '/function.php'; + + mkdir($directory, 0700); + file_put_contents($path, ' 1 && in_array($table, ['syslog_replication_receipts', 'syslog_replication_collectors'], true)) { diff --git a/tests/regression/live_pipeline_test.php b/tests/regression/live_pipeline_test.php new file mode 100644 index 00000000..ab2c6d5e --- /dev/null +++ b/tests/regression/live_pipeline_test.php @@ -0,0 +1,243 @@ + 'INSERT INTO syslog_alert (name, type, message, method, level, num, enabled, severity, user, email, command, recovery_command, notes, suppression_schedule) VALUES (?, ?, ?, ?, ?, 1, \'on\', 1, \'live-test\', \'\', ?, \'\', \'\', \'\')', + 'syslog_remove' => 'INSERT INTO syslog_remove (name, type, message, method, enabled, user) VALUES (?, ?, ?, ?, \'on\', \'live-test\')', + ]; + + syslog_db_execute_prepared($statements[$table], $values); +} + +function live_message($host, $program, $message) { + syslog_db_execute_prepared('INSERT INTO syslog_incoming (facility_id, priority_id, program, logtime, host, message) VALUES (1, 3, ?, NOW(), ?, ?)', [$program, $host, $message]); +} + +function live_process($work) { + $process = proc_open([PHP_BINARY, '-q', dirname(__DIR__, 2) . '/syslog_process.php'], [1 => ['file', $work . '/process.out', 'w'], 2 => ['file', $work . '/process.err', 'w']], $pipes); + + return is_resource($process) ? proc_close($process) : -1; +} + +function live_cleanup() { + syslog_db_execute("DELETE l FROM syslog_logs l INNER JOIN syslog_alert a ON a.id = l.alert_id WHERE a.user = 'live-test'"); + syslog_db_execute("DELETE FROM syslog_alert WHERE user = 'live-test'"); + syslog_db_execute("DELETE FROM syslog_remove WHERE user = 'live-test'"); + syslog_db_execute("DELETE FROM syslog_incoming WHERE host LIKE 'live-test%' OR host LIKE 'unresolved-live-test%'"); + syslog_db_execute("DELETE s FROM syslog s INNER JOIN syslog_hosts h ON h.host_id = s.host_id WHERE h.host LIKE 'live-test%'"); + syslog_db_execute("DELETE s FROM syslog_removed s INNER JOIN syslog_hosts h ON h.host_id = s.host_id WHERE h.host LIKE 'live-test%'"); + syslog_db_execute("DELETE FROM syslog_hosts WHERE host LIKE 'live-test%'"); + syslog_db_execute("DELETE FROM syslog_programs WHERE program LIKE 'live-test%'"); +} + +$saved_options = []; + +foreach (['syslog_enabled' => 'on', 'syslog_resolve_hostname' => '', 'syslog_html' => '', 'syslog_alert_cooldown_minutes' => '0', 'syslog_alert_deduplication_minutes' => '0'] as $name => $value) { + $saved_options[$name] = read_config_option($name, true); + set_config_option($name, $value); +} + +try { + live_cleanup(); + + // ---- Alert commands receive each value as one argument -------------------- + $hostile = [ + 'plain' => ['live-test-plain', 'plain message'], + 'spaces' => ['live-test host with spaces', 'message with spaces'], + 'quotes' => ["live-test-o'brien", 'say "hello" and \'bye\''], + 'metacharacters' => ['live-test-meta;rm', 'a; b | c & d > e < f $HOME `id` $(id) * ? [x] \\ !'], + 'host token' => ['live-test-ab', 'plain text'], + 'message token' => ['live-test-token', 'before and and after'], + 'both tokens' => ['live-test-', 'x y'], + 'newline' => ['live-test-newline', "first line\nsecond line"], + 'unicode' => ['live-test-ünï', 'ключ=значение'], + ]; + + $index = 0; + $stored = []; + + foreach ($hostile as $label => [$host, $message]) { + $index++; + $tag = 'LIVETAG' . $index . 'X'; + $script = $work . '/command-' . $index . '.php'; + $output = $work . '/arguments-' . $index . '.json'; + + file_put_contents($script, '#!' . PHP_BINARY . "\n ']); + live_message($host, 'live-test-app', $tag . ' ' . $message); + $stored[$label] = [(string) live_value('SELECT host FROM syslog_incoming WHERE message LIKE ?', [$tag . ' %']), (string) live_value('SELECT message FROM syslog_incoming WHERE message LIKE ?', [$tag . ' %'])]; + } + + live_check(live_process($work) === 0, 'The processor exits cleanly after the alert command messages, ' . trim((string) @file_get_contents($work . '/process.err'))); + + $index = 0; + + foreach ($hostile as $label => [$host, $message]) { + $index++; + $tag = 'LIVETAG' . $index . 'X'; + $output = $work . '/arguments-' . $index . '.json'; + $id = (int) live_value("SELECT id FROM syslog_alert WHERE name = ?", ["live-test $label"]); + $actual = is_file($output) ? json_decode(file_get_contents($output), true) : null; + [$stored_host, $stored_message] = $stored[$label]; + // Cacti's argument escaping removes line breaks, so a message arrives as one line. + $expected = [(string) $id, str_replace(["\n", "\r"], '', $stored_host), $syslog_levels[3], $syslog_facilities[1], str_replace(["\n", "\r"], '', $stored_message), $severities[1]]; + + live_check($stored_host === $host, "The $label host name is stored as received"); + + live_check($actual === $expected, "The alert command for the $label case gets six exact arguments" . ($actual === null ? ' (the command did not run)' : ($actual === $expected ? '' : ', got ' . json_encode($actual)))); + } + + // ---- Removal rules through the processor ------------------------------------ + live_cleanup(); + live_rule('syslog_remove', ['live-test archive host', 'host', 'live-test-archived', 'trans']); + live_rule('syslog_remove', ['live-test archive program', 'program', 'live-test-prog-archived', 'trans']); + live_rule('syslog_remove', ['live-test delete phrase', 'messagec', 'LIVE-DELETE-ME', 'del']); + live_rule('syslog_remove', ['live-test archive phrase', 'messageb', 'LIVE-ARCHIVE-ME', 'trans']); + + live_message('live-test-archived', 'live-test-app', 'goes to the removed table by host'); + live_message('live-test-kept', 'live-test-prog-archived', 'goes to the removed table by program'); + live_message('live-test-kept', 'live-test-app', 'LIVE-DELETE-ME vanishes'); + live_message('live-test-kept', 'live-test-app', 'LIVE-ARCHIVE-ME moves'); + live_message('live-test-kept', 'live-test-app', 'ordinary message stays'); + + live_check(live_process($work) === 0, 'The processor exits cleanly after the removal rule messages'); + live_check((int) live_value("SELECT COUNT(*) FROM syslog_removed r INNER JOIN syslog_hosts h ON h.host_id = r.host_id WHERE h.host LIKE 'live-test%'") === 3, 'Host, program and begins-with rules with the move method archive three messages'); + live_check((int) live_value("SELECT COUNT(*) FROM syslog s INNER JOIN syslog_hosts h ON h.host_id = s.host_id WHERE h.host LIKE 'live-test%'") === 1, 'One ordinary message reaches the main table'); + live_check((int) live_value("SELECT COUNT(*) FROM syslog_incoming WHERE host LIKE 'live-test%'") === 0, 'The queue is drained'); + live_check((int) live_value("SELECT COUNT(*) FROM syslog WHERE message LIKE '%LIVE-DELETE-ME%'") === 0 && (int) live_value("SELECT COUNT(*) FROM syslog_removed WHERE message LIKE '%LIVE-DELETE-ME%'") === 0, 'A delete rule leaves no copy behind'); + + // ---- An invalid alert rule does not silence the valid ones ------------------- + live_cleanup(); + live_rule('syslog_alert', ['live-test good immediate', 'messagec', 'LIVE-GOOD', 0, 0, '']); + live_rule('syslog_alert', ['live-test good per host', 'sql', "message LIKE '%LIVE-GOOD%'", 1, 1, '']); + live_rule('syslog_alert', ['live-test bad query', 'sql', "message LIKE '%x' AND (", 0, 0, '']); + live_rule('syslog_alert', ['live-test bad per host', 'sql', 'host IN (', 1, 1, '']); + + live_message('live-test-alerted', 'live-test-app', 'LIVE-GOOD message'); + live_check(live_process($work) === 0, 'The processor exits cleanly with invalid alert rules present'); + + $fired = syslog_db_fetch_assoc("SELECT DISTINCT a.name FROM syslog_logs l INNER JOIN syslog_alert a ON a.id = l.alert_id WHERE a.user = 'live-test' ORDER BY a.name"); + + live_check(array_column($fired, 'name') === ['live-test good immediate', 'live-test good per host'], 'Both valid rules fire and neither invalid rule does, fired: ' . implode(', ', array_column($fired, 'name'))); + + // ---- Dashboard queries over the processed messages -------------------------- + live_message('live-test-dash-a', 'live-test-app', 'dashboard one'); + live_message('live-test-dash-a', 'live-test-app', 'dashboard two'); + live_message('live-test-dash-b', 'live-test-app', 'dashboard three'); + live_check(live_process($work) === 0, 'The processor exits cleanly before the dashboard checks'); + + require_once dirname(__DIR__, 2) . '/lib/syslog_dashboard.php'; + + $range = "syslog.logtime BETWEEN NOW() - INTERVAL 1 HOUR AND NOW() + INTERVAL 1 HOUR AND syslog.message LIKE 'dashboard%'"; + + foreach (['', 'ONLY_FULL_GROUP_BY'] as $mode) { + syslog_db_execute_prepared('SET SESSION sql_mode = ?', [$mode]); + + foreach (['-1', '1', '2'] as $removal) { + $rows = syslog_db_fetch_assoc(syslog_dashboard_breakdown_sql('syslog', 'host', $removal, $range, 0)); + $map = $rows === false ? [] : array_column($rows, 'records', 'label'); + $want = $removal === '2' ? [] : ['live-test-dash-a' => 2, 'live-test-dash-b' => 1]; + + live_check(array_map('intval', $map) == $want, "The host breakdown for record type $removal is right in " . ($mode ?: 'default mode')); + + $series = syslog_db_fetch_assoc(syslog_dashboard_timeseries_sql('syslog', $removal, $range, 3600, 0)); + + live_check($series !== false && array_sum(array_column($series, 'records')) == ($removal === '2' ? 0 : 3), "The timeseries total for record type $removal is right in " . ($mode ?: 'default mode')); + } + } + + syslog_db_execute_prepared('SET SESSION sql_mode = ?', ['']); +} catch (Throwable $error) { + $failures[] = $error->getMessage() . ' (' . basename($error->getFile()) . ':' . $error->getLine() . ')'; +} finally { + live_cleanup(); + + foreach ($saved_options as $name => $value) { + set_config_option($name, $value === false ? '' : $value); + } + + $process_output = (string) @file_get_contents($work . '/process.out') . (string) @file_get_contents($work . '/process.err'); + + foreach (glob($work . '/*') ?: [] as $file) { + unlink($file); + } + + rmdir($work); + + if (is_file($log_file) && filesize($log_file) > $log_size) { + $handle = fopen($log_file, 'r+'); + ftruncate($handle, $log_size); + fclose($handle); + } +} + +if ($failures) { + fwrite(STDERR, 'Processor output: ' . trim(substr((string) $process_output, -1500)) . "\n"); + fwrite(STDERR, 'FAIL: ' . implode("\nFAIL: ", $failures) . "\n"); + + exit(1); +} + +echo "OK: $checks checks passed\n"; diff --git a/tests/regression/review_fixes_database_test.php b/tests/regression/review_fixes_database_test.php new file mode 100644 index 00000000..4b12ccea --- /dev/null +++ b/tests/regression/review_fixes_database_test.php @@ -0,0 +1,166 @@ + 'upgrade', 'engine' => 'InnoDB', 'days' => 30, 'db_type' => 'part'], false); + + $pdo->exec("INSERT INTO syslog_alert (name, type, message, method, num, user) VALUES ('rule', 'messagec', 'x', 0, 1, 'a-user-name-that-is-forty-characters-long')"); + $pdo->exec("INSERT INTO syslog_remove (name, type, message, method, user) VALUES ('rule', 'messagec', 'x', 'del', 'u')"); + $pdo->exec("INSERT INTO syslog_reports (name, type, message, user) VALUES ('rule', 'messagec', 'x', 'u')"); + $pdo->exec("INSERT INTO syslog_hosts (host) VALUES ('router-a')"); + $pdo->exec("INSERT INTO syslog_programs (program) VALUES ('app')"); + $pdo->exec("INSERT INTO syslog (logtime, host_id, program_id, facility_id, priority_id, message) VALUES (NOW(), 1, 1, 1, 3, 'kept until truncate')"); + + syslog_setup_table_new(['upgrade_type' => 'truncate', 'engine' => 'InnoDB', 'days' => 30, 'db_type' => 'part'], false); + + check(table_count('syslog') === 0, 'Truncate must discard stored messages'); + check(table_count('syslog_alert') === 1 && table_count('syslog_remove') === 1 && table_count('syslog_reports') === 1, 'Truncate must keep alert, removal and report rules'); + + // ---- Owner columns hold a full Cacti username ------------------------------ + foreach (['syslog_alert', 'syslog_remove', 'syslog_reports', 'syslog_device_rule'] as $table) { + $type = owner_column_type($table); + check($type === 'varchar(50)', "$table.user must be varchar(50), found $type"); + } + + $pdo->exec('ALTER TABLE syslog_alert MODIFY COLUMN `user` varchar(32) NOT NULL DEFAULT \'\''); + $pdo->exec('ALTER TABLE syslog_device_rule MODIFY COLUMN `user` varchar(32) NOT NULL DEFAULT \'\''); + // Cacti's table reconciler creates these in a live install; this run has no Cacti. + foreach ([ + 'CREATE TABLE IF NOT EXISTS syslog_saved_searches (id INT)', + 'CREATE TABLE IF NOT EXISTS syslog_saved_searches_perm (id INT)', + 'CREATE TABLE IF NOT EXISTS syslog_dashboards (id INT)', + 'CREATE TABLE IF NOT EXISTS syslog_dashboards_perm (id INT)', + 'CREATE TABLE IF NOT EXISTS syslog_dashboard_panels (id INT)', + ] as $statement) { + $pdo->exec($statement); + } + + check(syslog_ensure_table_structures(), 'Schema repair must succeed on a 32 character owner column'); + + foreach (['syslog_alert', 'syslog_device_rule'] as $table) { + $type = owner_column_type($table); + check($type === 'varchar(50)', "Repair must widen $table.user, found $type"); + } + + $marker = syslog_db_fetch_cell_prepared("SELECT value FROM syslog_status WHERE name = 'schema_user_width'"); + check($marker === '50', 'Widening must be recorded once it succeeded'); + + // ---- All records dashboard queries ----------------------------------------- + $pdo->exec('DELETE FROM syslog_hosts'); + $pdo->exec("INSERT INTO syslog_hosts (host_id, host) VALUES (1, 'a'), (2, 'b')"); + $pdo->exec("INSERT INTO syslog (logtime, host_id, program_id, facility_id, priority_id, message) VALUES + ('2026-10-01 00:10:00', 1, 1, 1, 3, 'm'), ('2026-10-01 00:20:00', 1, 1, 1, 3, 'm'), + ('2026-10-01 05:00:00', 2, 1, 1, 3, 'm')"); + $pdo->exec("INSERT INTO syslog_removed (logtime, host_id, program_id, facility_id, priority_id, message) VALUES + ('2026-10-01 05:30:00', 2, 1, 1, 3, 'm')"); + + $range = "syslog.logtime BETWEEN '2026-09-01 00:00:00' AND '2026-12-01 00:00:00'"; + + foreach (['', 'ONLY_FULL_GROUP_BY'] as $mode) { + set_sql_mode($mode); + + $rows = syslog_db_fetch_assoc(syslog_dashboard_breakdown_sql('syslog', 'host', '1', $range, 0)); + $byhost = $rows === false ? [] : array_column($rows, 'records', 'label'); + check($byhost == ['a' => 2, 'b' => 2], 'Breakdown must report every host with its own total (' . ($mode ?: 'default mode') . ')'); + + $rows = syslog_db_fetch_assoc(syslog_dashboard_timeseries_sql('syslog', '1', $range, 3600, 0)); + $buckets = $rows === false ? [] : array_map('intval', array_column($rows, 'records')); + sort($buckets); + check($rows !== false && count($rows) === 2 && $buckets == [2, 2], 'Timeseries must report each hour bucket (' . ($mode ?: 'default mode') . ')'); + } + + $pdo->exec("SET SESSION sql_mode = ''"); + + // ---- Archiving removal rules on host and program --------------------------- + $pdo->exec('DELETE FROM syslog_hosts'); + $pdo->exec("INSERT INTO syslog_hosts (host_id, host) VALUES (1, 'router1'), (2, 'router2')"); + $pdo->exec('DELETE FROM syslog_programs'); + $pdo->exec("INSERT INTO syslog_programs (program_id, program) VALUES (1, 'app'), (2, 'daemon')"); + $pdo->exec('DELETE FROM syslog_removed'); + $pdo->exec('DELETE FROM syslog_remove'); + + foreach ([['host', 'router1'], ['program', 'daemon']] as [$type, $value]) { + $pdo->exec('DELETE FROM syslog_incoming'); + $pdo->exec('DELETE FROM syslog_removed'); + $pdo->exec('DELETE FROM syslog_remove'); + $insert = $pdo->prepare("INSERT INTO syslog_remove (name, type, message, method, enabled, user) VALUES ('archive', ?, ?, 'trans', 'on', 'u')"); + $insert->execute([$type, $value]); + $pdo->exec("INSERT INTO syslog_incoming (status, logtime, host, program, message, facility_id, priority_id) VALUES + (1, NOW(), 'router1', 'app', 'one', 1, 3), (1, NOW(), 'router2', 'daemon', 'two', 1, 3), (1, NOW(), 'router2', 'app', 'three', 1, 3)"); + $max = (int) $pdo->query('SELECT MAX(seq) FROM syslog_incoming')->fetchColumn(); + + $result = syslog_remove_items('syslog_incoming', $max); + + check($result['xferred'] === 1, "Archiving a $type rule must move the matching message, moved " . $result['xferred']); + check(table_count('syslog_removed') === 1 && table_count('syslog_incoming') === 2, "Archiving a $type rule must leave the other messages queued"); + } + + // ---- Reprocess runs only the selected rule -------------------------------- + $pdo->exec('DELETE FROM syslog'); + $pdo->exec('DELETE FROM syslog_removed'); + $pdo->exec('DELETE FROM syslog_remove'); + $pdo->exec("INSERT INTO syslog (logtime, host_id, program_id, facility_id, priority_id, message) VALUES + (NOW(), 1, 1, 1, 3, 'alpha noise'), (NOW(), 1, 1, 1, 3, 'beta noise')"); + $pdo->exec("INSERT INTO syslog_remove (name, type, message, method, enabled, user) VALUES + ('alpha', 'messagec', 'alpha', 'del', 'on', 'u'), ('beta', 'messagec', 'beta', 'del', 'on', 'u')"); + $alpha = (int) $pdo->query("SELECT id FROM syslog_remove WHERE name = 'alpha'")->fetchColumn(); + + $result = syslog_remove_items('syslog', 0, $alpha); + + check($result['removed'] === 1, 'Reprocessing one rule must remove only its own messages'); + check(table_count('syslog') === 1, 'The other rule must not run during a reprocess'); + + // ---- One bad alert rule does not silence the others ------------------------ + $pdo->exec('DELETE FROM syslog_alert'); + $pdo->exec('DELETE FROM syslog_incoming'); + $pdo->exec('DELETE FROM syslog_logs'); + $pdo->exec("INSERT INTO syslog_alert (name, type, message, method, num, enabled, severity, user, email, command, recovery_command, notes) VALUES + ('good', 'sql', 'message LIKE ''%good%''', 1, 1, 'on', 3, 'u', '', '', '', ''), + ('bad', 'sql', 'message LIKE ''%bad%'' AND (', 1, 1, 'on', 3, 'u', '', '', '', '')"); + $pdo->exec("INSERT INTO syslog_incoming (status, logtime, host, program, message, facility_id, priority_id) VALUES + (1, NOW(), 'router1', 'app', 'a good message', 1, 3)"); + $max = (int) $pdo->query('SELECT MAX(seq) FROM syslog_incoming')->fetchColumn(); + + $combined = syslog_db_fetch_assoc_prepared("SELECT SUM(CASE WHEN (message LIKE '%good%') THEN 1 ELSE 0 END) AS m0, SUM(CASE WHEN (message LIKE '%bad%' AND () THEN 1 ELSE 0 END) AS m1 FROM syslog_incoming WHERE status = 1 AND seq <= ?", [$max]); + check($combined === false, 'The fixture must make the combined count query fail'); + + $result = syslog_process_alerts($max); + + check($result['syslog_alerts'] === 2, 'Both rules are loaded'); + check($result['syslog_alarms'] === 1, 'The valid rule must still fire when another rule cannot run, fired ' . $result['syslog_alarms']); + + // ---- Host-level SQL rules with an asterisk --------------------------------- + $pdo->exec('DELETE FROM syslog_alert'); + $pdo->exec('DELETE FROM syslog_logs'); + $pdo->exec('DELETE FROM syslog_alert_counts'); + $pdo->exec('DELETE FROM syslog_threshold_state'); + $pdo->exec("INSERT INTO syslog_alert (name, type, message, method, num, level, enabled, severity, user, email, command, recovery_command, notes) VALUES + ('regexp', 'sql', 'message REGEXP ''link.*down''', 1, 1, 1, 'on', 3, 'u', '', '', '', '')"); + $pdo->exec('DELETE FROM syslog_incoming'); + $pdo->exec("INSERT INTO syslog_incoming (status, logtime, host, program, message, facility_id, priority_id) VALUES + (1, NOW(), 'router1', 'app', 'link is down', 1, 3)"); + $max = (int) $pdo->query('SELECT MAX(seq) FROM syslog_incoming')->fetchColumn(); + + $result = syslog_process_alerts($max); + + check($result['syslog_alarms'] === 1, 'A host-level rule that uses an asterisk must fire'); +} catch (Throwable $error) { + $failures[] = $error->getMessage() . ' (' . basename($error->getFile()) . ':' . $error->getLine() . ')'; +} finally { + drop_database(); +} + +finish_checks(); diff --git a/tests/regression/review_fixes_paths_database_test.php b/tests/regression/review_fixes_paths_database_test.php new file mode 100644 index 00000000..b041467c --- /dev/null +++ b/tests/regression/review_fixes_paths_database_test.php @@ -0,0 +1,425 @@ +exec("INSERT INTO syslog_hosts (host_id, host) VALUES (1, 'router1'), (2, 'router2'), (3, 'switch3')"); + $pdo->exec("INSERT INTO syslog_programs (program_id, program) VALUES (1, 'app'), (2, 'daemon')"); +} + +function scalar($sql, $params = []) { + $statement = $GLOBALS['pdo']->prepare($sql); + $statement->execute($params); + + return $statement->fetchColumn(); +} + +function ids($sql, $params = []) { + $statement = $GLOBALS['pdo']->prepare($sql); + $statement->execute($params); + + return $statement->fetchAll(PDO::FETCH_COLUMN); +} + +function reset_rules_and_data($pdo) { + foreach (['syslog_alert', 'syslog_remove', 'syslog_reports', 'syslog_device_rule', 'syslog', 'syslog_removed', 'syslog_incoming', 'syslog_logs', 'syslog_alert_counts', 'syslog_threshold_state', 'syslog_threshold_messages', 'syslog_hosts', 'syslog_programs'] as $table) { + $pdo->exec(['syslog_alert' => 'DELETE FROM syslog_alert', 'syslog_remove' => 'DELETE FROM syslog_remove', 'syslog_reports' => 'DELETE FROM syslog_reports', + 'syslog_device_rule' => 'DELETE FROM syslog_device_rule', 'syslog' => 'DELETE FROM syslog', 'syslog_removed' => 'DELETE FROM syslog_removed', + 'syslog_incoming' => 'DELETE FROM syslog_incoming', 'syslog_logs' => 'DELETE FROM syslog_logs', 'syslog_alert_counts' => 'DELETE FROM syslog_alert_counts', + 'syslog_threshold_state' => 'DELETE FROM syslog_threshold_state', 'syslog_threshold_messages' => 'DELETE FROM syslog_threshold_messages', + 'syslog_hosts' => 'DELETE FROM syslog_hosts', 'syslog_programs' => 'DELETE FROM syslog_programs'][$table]); + } +} + +function insert_rule($pdo, $table, array $values) { + $statements = [ + 'syslog_alert' => 'INSERT INTO syslog_alert (name, type, message, method, level, num, enabled, severity, user, email, command, recovery_command, notes) VALUES (?, ?, ?, ?, ?, ?, \'on\', 1, \'u\', \'\', ?, \'\', \'\')', + 'syslog_remove' => 'INSERT INTO syslog_remove (name, type, message, method, enabled, user) VALUES (?, ?, ?, ?, \'on\', \'u\')', + ]; + + $pdo->prepare($statements[$table])->execute($values); +} + +try { + fresh_database(); + + // ---- Install and upgrade choices on a database that holds data ---------------- + $install = ['upgrade_type' => 'upgrade', 'engine' => 'InnoDB', 'days' => 30, 'db_type' => 'part']; + + syslog_setup_table_new($install, false); + check(table_count('syslog') === 0 && table_count('syslog_alert') === 0, 'A new install creates empty tables'); + check((int) scalar('SELECT COUNT(*) FROM syslog_facilities') === 24 && (int) scalar('SELECT COUNT(*) FROM syslog_priorities') === 9, 'A new install seeds the lookup tables'); + check(syslog_ensure_table_structures() === false, 'Schema repair reports the tables Cacti creates in a live install as missing'); + + foreach ([ + 'CREATE TABLE IF NOT EXISTS syslog_saved_searches (id INT)', + 'CREATE TABLE IF NOT EXISTS syslog_saved_searches_perm (id INT)', + 'CREATE TABLE IF NOT EXISTS syslog_dashboards (id INT)', + 'CREATE TABLE IF NOT EXISTS syslog_dashboards_perm (id INT)', + 'CREATE TABLE IF NOT EXISTS syslog_dashboard_panels (id INT)', + ] as $statement) { + $pdo->exec($statement); + } + + check(syslog_ensure_table_structures() === true, 'Schema repair succeeds once those tables exist'); + + $seed = static function () use ($pdo) { + reset_rules_and_data($pdo); + seed_reference_data($pdo); + insert_rule($pdo, 'syslog_alert', ['kept alert', 'messagec', 'x', 0, 0, 1, '']); + insert_rule($pdo, 'syslog_remove', ['kept removal', 'messagec', 'x', 'del']); + $pdo->exec("INSERT INTO syslog_reports (name, type, message, user) VALUES ('kept report', 'messagec', 'x', 'u')"); + $pdo->exec("INSERT INTO syslog_device_rule (host, user) VALUES ('router1', 'u')"); + $pdo->exec("INSERT INTO syslog (logtime, host_id, program_id, facility_id, priority_id, message) VALUES (NOW(), 1, 1, 1, 3, 'stored one'), (NOW(), 2, 2, 1, 3, 'stored two')"); + $pdo->exec("INSERT INTO syslog_removed (logtime, host_id, program_id, facility_id, priority_id, message) VALUES (NOW(), 1, 1, 1, 3, 'removed one')"); + $pdo->exec("INSERT INTO syslog_incoming (status, logtime, host, program, message, facility_id, priority_id) VALUES (1, NOW(), 'router1', 'app', 'queued', 1, 3)"); + }; + + $counts = static fn() => [ + 'syslog' => table_count('syslog'), 'removed' => table_count('syslog_removed'), 'incoming' => table_count('syslog_incoming'), + 'alert' => table_count('syslog_alert'), 'remove' => table_count('syslog_remove'), 'reports' => table_count('syslog_reports'), + 'device' => (int) scalar('SELECT COUNT(*) FROM syslog_device_rule'), 'hosts' => (int) scalar('SELECT COUNT(*) FROM syslog_hosts'), + ]; + + foreach (['inline', 'background', 'upgrade'] as $choice) { + $seed(); + $before = $counts(); + syslog_setup_table_new(['upgrade_type' => $choice] + $install, true); + check($counts() === $before, "The $choice upgrade keeps every row"); + } + + $seed(); + syslog_setup_table_new(['upgrade_type' => 'truncate'] + $install, false); + check($counts() === ['syslog' => 0, 'removed' => 0, 'incoming' => 0, 'alert' => 1, 'remove' => 1, 'reports' => 1, 'device' => 1, 'hosts' => 0], + 'Truncate discards messages, queue and devices, and keeps all four kinds of rule'); + check((int) scalar('SELECT COUNT(*) FROM syslog_facilities') === 24, 'Truncate re-seeds the lookup tables'); + + // ---- Dashboard queries for every record type and dimension -------------------- + reset_rules_and_data($pdo); + seed_reference_data($pdo); + $pdo->exec("INSERT INTO syslog_facilities (facility_id, facility) VALUES (99, 'spare') ON DUPLICATE KEY UPDATE facility = facility"); + + $main = [[1, 1, 1, 3], [1, 1, 1, 3], [2, 2, 1, 4], [3, 1, 16, 3], [3, 2, 16, 4]]; + $removed = [[1, 2, 1, 3], [2, 2, 16, 3]]; + + foreach ($main as $index => [$host, $program, $facility, $priority]) { + $pdo->prepare('INSERT INTO syslog (logtime, host_id, program_id, facility_id, priority_id, message) VALUES (?, ?, ?, ?, ?, ?)') + ->execute([sprintf('2026-10-01 0%d:10:00', $index), $host, $program, $facility, $priority, 'm']); + } + + foreach ($removed as $index => [$host, $program, $facility, $priority]) { + $pdo->prepare('INSERT INTO syslog_removed (logtime, host_id, program_id, facility_id, priority_id, message) VALUES (?, ?, ?, ?, ?, ?)') + ->execute([sprintf('2026-10-01 0%d:40:00', $index), $host, $program, $facility, $priority, 'm']); + } + + $pdo->exec("INSERT INTO syslog_logs (alert_id, logseq, logtime, logmsg, host, facility_id, priority_id, program_id, count) VALUES + (1, 1, '2026-10-01 01:00:00', 'a', 'router1', 1, 3, 1, 4), (1, 2, '2026-10-01 01:30:00', 'b', 'router1', 1, 3, 1, 2), (2, 3, '2026-10-01 02:00:00', 'c', 'router2', 16, 4, 2, 5)"); + + $range = "syslog.logtime BETWEEN '2026-09-01 00:00:00' AND '2026-12-01 00:00:00'"; + $values = [ + 'host' => ['router1' => 0, 'router2' => 0, 'switch3' => 0], 'program' => ['app' => 0, 'daemon' => 0], + 'facility' => ['user' => 0, 'local0' => 0], 'priority' => ['err' => 0, 'warning' => 0], + ]; + $column = ['host' => 0, 'program' => 1, 'facility' => 2, 'priority' => 3]; + $tables = ['-1' => [$main], '2' => [$removed], '1' => [$main, $removed], '0' => [$main, $removed]]; + + $pdo->exec("UPDATE syslog_facilities SET facility = 'user' WHERE facility_id = 1"); + $pdo->exec("UPDATE syslog_facilities SET facility = 'mail' WHERE facility_id = 2"); + $pdo->exec("UPDATE syslog_facilities SET facility = 'local0' WHERE facility_id = 16"); + $pdo->exec("UPDATE syslog_priorities SET priority = 'err' WHERE priority_id = 3"); + $pdo->exec("UPDATE syslog_priorities SET priority = 'warning' WHERE priority_id = 4"); + + $names = [ + 'host' => [1 => 'router1', 2 => 'router2', 3 => 'switch3'], 'program' => [1 => 'app', 2 => 'daemon'], + 'facility' => [1 => 'user', 2 => 'mail', 16 => 'local0'], 'priority' => [3 => 'err', 4 => 'warning'], + ]; + + foreach (['', 'ONLY_FULL_GROUP_BY'] as $mode) { + set_sql_mode($mode); + + foreach ($tables as $removal => $sources) { + foreach ($names as $field => $labels) { + $expected = []; + + foreach ($sources as $rows) { + foreach ($rows as $row) { + $label = $labels[$row[$column[$field]]]; + $expected[$label] = ($expected[$label] ?? 0) + 1; + } + } + + $rows = syslog_db_fetch_assoc(syslog_dashboard_breakdown_sql('syslog', $field, (string) $removal, $range, 0)); + $actual = $rows === false ? ['error'] : array_map('intval', array_column($rows, 'records', 'label')); + ksort($expected); + ksort($actual); + check($actual == $expected, "Breakdown by $field for record type $removal in " . ($mode ?: 'default mode') . ' returns every label with its own total'); + } + + $total = array_sum(array_map('count', $sources)); + $rows = syslog_db_fetch_assoc(syslog_dashboard_timeseries_sql('syslog', (string) $removal, $range, 3600, 0)); + check($rows !== false && array_sum(array_column($rows, 'records')) == $total && count($rows) === count(array_unique(array_column($rows, 'bucket'))), + "Timeseries for record type $removal in " . ($mode ?: 'default mode') . ' sums to the record total with one row per bucket'); + } + + $rows = syslog_db_fetch_assoc(syslog_dashboard_breakdown_sql('alerts', 'host', '1', $range, 0)); + check($rows !== false && array_map('intval', array_column($rows, 'records', 'label')) == ['router1' => 6, 'router2' => 5], 'Alert breakdown by host sums the grouped counts (' . ($mode ?: 'default mode') . ')'); + + foreach ([['program', ['app' => 6, 'daemon' => 5]], ['facility', ['user' => 6, 'local0' => 5]], ['priority', ['err' => 6, 'warning' => 5]]] as [$field, $expected]) { + $rows = syslog_db_fetch_assoc(syslog_dashboard_breakdown_sql('alerts', $field, '1', $range, 0)); + check($rows !== false && array_map('intval', array_column($rows, 'records', 'label')) == $expected, "Alert breakdown by $field returns each label (" . ($mode ?: 'default mode') . ')'); + } + + $rows = syslog_db_fetch_assoc(syslog_dashboard_timeseries_sql('alerts', '1', $range, 3600, 0)); + check($rows !== false && array_sum(array_column($rows, 'records')) == 11, 'Alert timeseries sums the grouped counts (' . ($mode ?: 'default mode') . ')'); + } + + set_sql_mode(''); + + // ---- Removal rules of every type and method ---------------------------------- + $facility_rule_value = ['syslog_incoming' => '1', 'syslog' => 'user']; + $filter = static fn($field, $value) => json_encode(['version' => 1, 'conditions' => [['join' => 'AND', 'negative' => false, 'field' => $field, 'operator' => '=', 'value' => $value]]]); + + foreach (['syslog_incoming', 'syslog'] as $table) { + $rules = [ + 'host' => ['host', 'router1', 2], + 'program' => ['program', 'daemon', 2], + 'facility' => ['facility', $facility_rule_value[$table], 3], + 'messageb' => ['messageb', 'alpha', 2], + 'messagec' => ['messagec', 'one', 2], + 'messagee' => ['messagee', 'two', 2], + 'sql' => ['sql', $table === 'syslog' ? 'host_id = 1' : "host = 'router1'", 2], + 'filter' => ['filter', $table === 'syslog' ? $filter('host_id', '1') : $filter('host', 'router1'), 2], + ]; + + foreach ($rules as $label => [$type, $message, $expected]) { + foreach (['del', 'trans'] as $method) { + reset_rules_and_data($pdo); + seed_reference_data($pdo); + $rows = [ + ['router1', 'app', 'alpha one', 1], ['router1', 'daemon', 'beta two', 2], ['router2', 'daemon', 'alpha three one', 1], + ['switch3', 'app', 'gamma two', 1], ['router2', 'app', 'delta', 2], + ]; + $hosts = ['router1' => 1, 'router2' => 2, 'switch3' => 3]; + $progs = ['app' => 1, 'daemon' => 2]; + + foreach ($rows as [$host, $program, $text, $facility]) { + $pdo->prepare('INSERT INTO syslog_incoming (status, logtime, host, program, message, facility_id, priority_id) VALUES (1, NOW(), ?, ?, ?, ?, 3)')->execute([$host, $program, $text, $facility]); + $pdo->prepare('INSERT INTO syslog (logtime, host_id, program_id, facility_id, priority_id, message) VALUES (NOW(), ?, ?, ?, 3, ?)')->execute([$hosts[$host], $progs[$program], $facility, $text]); + } + + insert_rule($pdo, 'syslog_remove', ["$label $method", $type, $message, $method]); + $max = (int) scalar('SELECT MAX(seq) FROM syslog_incoming'); + + $result = $table === 'syslog_incoming' ? syslog_remove_items('syslog_incoming', $max) : syslog_remove_items('syslog', 0, (int) scalar('SELECT id FROM syslog_remove')); + + $left = (int) scalar($table === 'syslog_incoming' ? 'SELECT COUNT(*) FROM syslog_incoming' : 'SELECT COUNT(*) FROM syslog'); + + if ($method === 'del') { + check($result['removed'] === $expected && $result['xferred'] === 0 && $left === 5 - $expected && table_count('syslog_removed') === 0, + "A $label rule with delete on $table removes $expected rows and archives none, removed {$result['removed']}"); + } else { + check($result['xferred'] === $expected && $left === 5 - $expected && table_count('syslog_removed') === $expected, + "A $label rule with move on $table archives $expected rows and leaves the rest, moved {$result['xferred']}"); + } + } + } + } + + $statement = $pdo->prepare("INSERT INTO syslog_remove (name, type, message, method, enabled, user) VALUES ('disabled', 'host', 'router1', 'del', '', 'u')"); + reset_rules_and_data($pdo); + seed_reference_data($pdo); + $pdo->exec("INSERT INTO syslog (logtime, host_id, program_id, facility_id, priority_id, message) VALUES (NOW(), 1, 1, 1, 3, 'a')"); + $statement->execute(); + $only = (int) scalar('SELECT id FROM syslog_remove'); + check(syslog_remove_items('syslog', 0, $only) === ['removed' => 0, 'xferred' => 0] && table_count('syslog') === 1, 'Reprocessing a disabled rule changes nothing'); + check(syslog_remove_items('syslog', 0, $only + 100) === ['removed' => 0, 'xferred' => 0], 'Reprocessing an unknown rule changes nothing'); + + // ---- Alert evaluation with an invalid rule among valid ones ------------------- + reset_rules_and_data($pdo); + $options['syslog_html'] = 'on'; + $options['syslog_maxrecords'] = 3; + + foreach (range(1, 12) as $number) { + $pdo->prepare('INSERT INTO syslog_incoming (status, logtime, host, program, message, facility_id, priority_id) VALUES (1, NOW(), ?, \'app\', ?, 1, 3)') + ->execute([$number <= 8 ? 'router1' : 'router2', "ROW-$number hit tok"]); + } + + $pdo->exec("INSERT INTO syslog_incoming (status, logtime, host, program, message, facility_id, priority_id) VALUES (1, NOW(), 'router3', 'app', 'link is down', 1, 3), (1, NOW(), 'router3', 'app', 'other', 1, 3)"); + + insert_rule($pdo, 'syslog_alert', ['immediate', 'sql', "message LIKE '%ROW-1 %'", 0, 0, 1, '']); + insert_rule($pdo, 'syslog_alert', ['threshold limited', 'sql', "message LIKE '%hit tok%'", 1, 0, 1, '']); + insert_rule($pdo, 'syslog_alert', ['per host asterisk', 'sql', "message REGEXP 'link.*down'", 1, 1, 1, '']); + insert_rule($pdo, 'syslog_alert', ['broken one', 'sql', "message LIKE 'x' AND (", 0, 0, 1, '']); + insert_rule($pdo, 'syslog_alert', ['broken two', 'sql', 'host IN (', 1, 1, 1, '']); + insert_rule($pdo, 'syslog_alert', ['unknown type', 'nonsense', 'x', 0, 0, 1, '']); + $max = (int) scalar('SELECT MAX(seq) FROM syslog_incoming'); + + $result = syslog_process_alerts($max); + $fired = ids('SELECT DISTINCT a.name FROM syslog_logs l INNER JOIN syslog_alert a ON a.id = l.alert_id ORDER BY a.name'); + + check($result['syslog_alerts'] === 6 && $fired == ['immediate', 'per host asterisk', 'threshold limited'], 'Valid rules of every kind fire while two invalid rules are skipped, fired: ' . implode(', ', $fired) . ', loaded ' . $result['syslog_alerts']); + + $html = (string) scalar("SELECT l.html FROM syslog_logs l INNER JOIN syslog_alert a ON a.id = l.alert_id WHERE a.name = 'threshold limited'"); + check(substr_count($html, 'ROW-') === 3, 'The threshold alert lists 3 of 12 matches, listed ' . substr_count($html, 'ROW-')); + + foreach ([[0, 12], [20, 12]] as [$limit, $listed]) { + $pdo->exec('DELETE FROM syslog_logs'); + $pdo->exec('DELETE FROM syslog_alert_counts'); + $pdo->exec('DELETE FROM syslog_threshold_state'); + $pdo->exec('DELETE FROM syslog_threshold_messages'); + $pdo->exec("UPDATE syslog_incoming SET status = 1"); + $options['syslog_maxrecords'] = $limit; + syslog_process_alerts($max); + $html = (string) scalar("SELECT l.html FROM syslog_logs l INNER JOIN syslog_alert a ON a.id = l.alert_id WHERE a.name = 'threshold limited'"); + check(substr_count($html, 'ROW-') === $listed, "With the limit set to $limit all $listed matches are listed, listed " . substr_count($html, 'ROW-')); + } + + // ---- Owner columns at the boundary -------------------------------------------- + $fifty = str_repeat('u', 50); + $pdo->exec("SET SESSION sql_mode = 'STRICT_ALL_TABLES'"); + reset_rules_and_data($pdo); + + foreach (['syslog_alert' => 'INSERT INTO syslog_alert (name, type, message, method, user, suppression_schedule) VALUES (\'o\', \'messagec\', \'x\', 0, ?, \'\')', + 'syslog_remove' => 'INSERT INTO syslog_remove (name, type, message, method, user) VALUES (\'o\', \'messagec\', \'x\', \'del\', ?)', + 'syslog_reports' => 'INSERT INTO syslog_reports (name, type, message, user) VALUES (\'o\', \'messagec\', \'x\', ?)', + 'syslog_device_rule' => 'INSERT INTO syslog_device_rule (host, user) VALUES (\'h\', ?)'] as $table => $sql) { + $pdo->prepare($sql)->execute([$fifty]); + check(scalar('SELECT user FROM ' . ['syslog_alert' => 'syslog_alert', 'syslog_remove' => 'syslog_remove', 'syslog_reports' => 'syslog_reports', 'syslog_device_rule' => 'syslog_device_rule'][$table]) === $fifty, "$table stores a 50 character owner"); + + try { + $pdo->prepare($sql)->execute([$fifty . 'x']); + check(false, "$table accepted a 51 character owner in strict mode"); + } catch (PDOException $error) { + check(true, ''); + } + } + + $pdo->exec("SET SESSION sql_mode = ''"); + + // ---- The widening is retried until it fully succeeds ---------------------------- + $marker = static fn() => scalar("SELECT value FROM syslog_status WHERE name = 'schema_user_width'"); + + $pdo->exec("DELETE FROM syslog_status WHERE name = 'schema_user_width'"); + $pdo->exec("CREATE TRIGGER block_marker BEFORE INSERT ON syslog_status FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'blocked'"); + check(syslog_ensure_table_structures() === false && $marker() === false, 'A marker that cannot be stored makes the check fail and leaves no marker'); + $pdo->exec('DROP TRIGGER block_marker'); + check(syslog_ensure_table_structures() === true && $marker() === '50', 'The next check widens again and stores the marker'); + check(syslog_ensure_table_structures() === true && (int) scalar('SELECT COUNT(*) FROM syslog_status WHERE name = \'schema_user_width\'') === 1, 'A later check does nothing more'); + + $pdo->exec("DELETE FROM syslog_status WHERE name = 'schema_user_width'"); + $pdo->exec('RENAME TABLE syslog_device_rule TO syslog_device_rule_real'); + $pdo->exec('CREATE VIEW syslog_device_rule AS SELECT * FROM syslog_device_rule_real'); + check(syslog_ensure_table_structures() === false && $marker() === false, 'A table that cannot be altered makes the check fail and leaves no marker'); + $pdo->exec('DROP VIEW syslog_device_rule'); + $pdo->exec('RENAME TABLE syslog_device_rule_real TO syslog_device_rule'); + check(syslog_ensure_table_structures() === true && $marker() === '50', 'Once the table can be altered the widening completes'); + + // ---- Hostname lookup budget ---------------------------------------------------- + reset_rules_and_data($pdo); + $pdo->exec('CREATE TABLE IF NOT EXISTS host (id INT AUTO_INCREMENT PRIMARY KEY, hostname VARCHAR(100), description VARCHAR(100))'); + $pdo->exec('DELETE FROM host'); + $pdo->exec("INSERT INTO host (hostname, description) VALUES ('known-device.invalid', 'Known Device')"); + $options['syslog_resolve_hostname'] = 'on'; + $options['syslog_no_dns'] = ''; + + foreach (range(1, 40) as $number) { + $pdo->prepare("INSERT INTO syslog_incoming (status, logtime, host, program, message, facility_id, priority_id) VALUES (1, NOW(), ?, 'app', 'm', 1, 3)")->execute(["unresolvable-$number.invalid"]); + } + + $pdo->exec("INSERT INTO syslog_incoming (status, logtime, host, program, message, facility_id, priority_id) VALUES (1, NOW(), 'known-device.invalid', 'app', 'm', 1, 3)"); + $max = (int) scalar('SELECT MAX(seq) FROM syslog_incoming'); + + $pdo->exec("INSERT INTO syslog_incoming (status, logtime, host, program, message, facility_id, priority_id) VALUES (1, NOW(), 'localhost', 'app', 'm', 1, 3)"); + $max = (int) scalar('SELECT MAX(seq) FROM syslog_incoming'); + + $resolved = syslog_resolve_incoming_hosts($max, 0, 0, 0); + check($resolved === 1, 'With no budget only the Cacti device lookup resolves a name, resolved ' . $resolved); + check((int) scalar("SELECT COUNT(*) FROM syslog_incoming WHERE host LIKE 'unresolved-%'") === 0, 'With no budget no name is called unresolved'); + check((int) scalar("SELECT COUNT(*) FROM syslog_incoming WHERE host = 'Known Device'") === 1, 'A name found in the Cacti device list is renamed to its description'); + check((int) scalar("SELECT COUNT(*) FROM syslog_incoming WHERE host LIKE 'unresolvable-%'") === 40, 'With no budget the sender names are left as received'); + + $resolved = syslog_resolve_incoming_hosts($max, 0, 0, 60); + check((int) scalar("SELECT COUNT(*) FROM syslog_incoming WHERE host = 'localhost'") === 1 && $resolved >= 1, 'A name that resolves through DNS keeps its name'); + $prefixed = (int) scalar("SELECT COUNT(*) FROM syslog_incoming WHERE host LIKE 'unresolved-unresolvable-%'"); + $untouched = (int) scalar("SELECT COUNT(*) FROM syslog_incoming WHERE host LIKE 'unresolvable-%'"); + check($prefixed + $untouched === 40 && $prefixed > 0, "A generous budget looks names up and marks the failures: $prefixed marked, $untouched left"); + + $options = array_merge($options, ['syslog_no_dns' => 'on']); + $pdo->exec("UPDATE syslog_incoming SET host = CONCAT('late-', seq)"); + syslog_resolve_incoming_hosts($max, 0, 0, 60); + check((int) scalar("SELECT COUNT(*) FROM syslog_incoming WHERE host LIKE 'unresolved-late-%'") === 42, 'With lookups disabled every unknown name is marked unresolved'); + + // ---- Rule imports against the real tables ------------------------------------- + reset_rules_and_data($pdo); + + foreach ([ + 'DROP TABLE IF EXISTS syslog_saved_searches', + 'CREATE TABLE syslog_saved_searches (id INT AUTO_INCREMENT PRIMARY KEY, hash VARCHAR(32) NOT NULL DEFAULT \'\', name VARCHAR(128) NOT NULL DEFAULT \'\', search TEXT, removal INT NOT NULL DEFAULT 1, `grouping` INT NOT NULL DEFAULT 0, user VARCHAR(50) NOT NULL DEFAULT \'\', is_global CHAR(2) NOT NULL DEFAULT \'\', date INT NOT NULL DEFAULT 0)', + ] as $statement) { + $pdo->exec($statement); + } + + $GLOBALS['db'] = $syslogdb_default; + $_SESSION['sess_user_id'] = 1; + $GLOBALS['syslog_allow_rule_edits_value'] = true; + + $import_alert = extract_page_function('syslog_alerts.php', 'alert_import'); + $import_search = extract_page_function('syslog_saved_searches.php', 'syslog_saved_search_import'); + $run = static function ($function, $table, array $templates, $raw = null) { + $GLOBALS['messages'] = []; + $GLOBALS['request']['import_text'] = $raw ?? json_encode(['version' => 1, 'table' => $table, 'templates' => $templates]); + $function(); + }; + + $alert = ['name' => 'imported', 'severity' => 1, 'method' => 0, 'suppression_schedule' => '', 'type' => 'messagec', 'message' => 'error']; + + $run($import_alert, 'syslog_alert', [$alert + ['hash' => str_repeat('a', 32)]]); + check(table_count('syslog_alert') === 1, 'A valid alert file imports one rule'); + + $run($import_alert, 'syslog_alert', [array_merge($alert, ['hash' => str_repeat('a', 32), 'id' => 999, 'message' => 'changed'])]); + check(table_count('syslog_alert') === 1 && scalar('SELECT message FROM syslog_alert') === 'changed', 'A second import with the same hash updates that rule and ignores a hostile id'); + + $run($import_alert, 'syslog_alert', [], '{"version":1,"table":"syslog_alert","templates":['); + $run($import_alert, 'syslog_alert', [], 'not json at all'); + $run($import_alert, 'syslog_alert', [['name' => 'wrong table']], json_encode(['version' => 1, 'table' => 'syslog_remove', 'templates' => [['name' => 'x']]])); + $run($import_alert, 'syslog_alert', [array_merge($alert, ['hash' => str_repeat('b', 32), 'type' => 'sql', 'message' => 'message LIKE ('])]); + $run($import_alert, 'syslog_alert', [array_merge($alert, ['hash' => str_repeat('c', 32), 'type' => 'filter', 'message' => '{"version":1,"conditions":[{"field":"nope"}]}'])]); + $run($import_alert, 'syslog_alert', [array_merge($alert, ['hash' => str_repeat('d', 32), 'type' => 'unknown'])]); + $run($import_alert, 'syslog_alert', [array_diff_key($alert, ['type' => 1])]); + check(table_count('syslog_alert') === 1, 'Malformed, wrong-table, invalid-query, invalid-filter, unknown-type and typeless alert files import nothing'); + + $run($import_alert, 'syslog_alert', [array_diff_key($alert, ['hash' => 1]) + ['name' => 'no hash']]); + check(table_count('syslog_alert') === 2, 'A file without a hash still imports a new rule'); + + $search = ['name' => 'saved', 'search' => 'message contains "error"', 'removal' => 1, 'grouping' => 0]; + + $run($import_search, 'syslog_saved_searches', [$search]); + $run($import_search, 'syslog_saved_searches', [array_merge($search, ['name' => 'sql in text', 'search' => 'message contains "a\'); DROP TABLE syslog; --"'])]); + check((int) scalar('SELECT COUNT(*) FROM syslog_saved_searches') === 2 && table_count('syslog') === 0, 'Valid searches import, and text that looks like SQL is stored as text'); + + foreach ([ + ['unterminated', ['search' => 'host = "unterminated']], ['bad record type', ['removal' => 7]], ['bad grouping', ['grouping' => 5]], + ['empty name', ['name' => '']], ['long name', ['name' => str_repeat('n', 129)]], ['empty search group', ['search' => '(']], + ] as [$label, $override]) { + $run($import_search, 'syslog_saved_searches', [array_merge($search, ['name' => $label], $override)]); + } + + check((int) scalar('SELECT COUNT(*) FROM syslog_saved_searches') === 2, 'Searches that do not parse or carry invalid values import nothing'); + + $run($import_search, 'syslog_saved_searches', [], '{"version":1,"table":"syslog_saved_searches","templates":"x"}'); + $run($import_search, 'syslog_saved_searches', [], '[]'); + check((int) scalar('SELECT COUNT(*) FROM syslog_saved_searches') === 2, 'Empty and malformed search files import nothing'); +} catch (Throwable $error) { + $failures[] = $error->getMessage() . ' (' . basename($error->getFile()) . ':' . $error->getLine() . ')'; +} finally { + drop_database(); +} + +finish_checks(); diff --git a/tests/regression/support/database_shim.php b/tests/regression/support/database_shim.php new file mode 100644 index 00000000..14a5eab4 --- /dev/null +++ b/tests/regression/support/database_shim.php @@ -0,0 +1,373 @@ + PDO::ERRMODE_EXCEPTION]); + +$syslogdb_default = 'syslog_review_check'; +$config = ['poller_id' => 1, 'base_path' => sys_get_temp_dir()]; +$syslog_cnn = new stdClass(); +$options = []; +$affected = 0; +$checks = 0; +$failures = []; +error_reporting(E_ALL & ~E_DEPRECATED); +$settings = ['syslog' => []]; +$severities = [3 => 'Error']; +$syslog_levels = [3 => 'err']; +$syslog_facilities = [1 => 'user']; +$syslog_incoming_config = [ + 'textField' => 'message', 'hostField' => 'host', 'programField' => 'program', + 'facilityField' => 'facility_id', 'priorityField' => 'priority_id', 'id' => 'seq', +]; + +function check($condition, $message) { + $GLOBALS['checks']++; + + if (!$condition) { + $GLOBALS['failures'][] = $message; + } +} + +function run_sql($sql, $params = []) { + $statement = $GLOBALS['pdo']->prepare($sql); + + try { + $ok = $statement->execute($params); + } catch (PDOException $error) { + $GLOBALS['last_error'] = $error->getMessage(); + + return false; + } + + $GLOBALS['affected'] = $statement->rowCount(); + + return $ok; +} + +function syslog_db_execute($sql, ...$args) { + return run_sql($sql); +} + +function syslog_db_execute_prepared($sql, $params = [], ...$args) { + return run_sql($sql, $params); +} + +function syslog_db_fetch_cell_prepared($sql, $params = [], ...$args) { + $statement = $GLOBALS['pdo']->prepare($sql); + + try { + $statement->execute($params); + } catch (PDOException $error) { + return false; + } + + return $statement->fetchColumn(); +} + +function syslog_db_fetch_cell($sql, ...$args) { + return syslog_db_fetch_cell_prepared($sql); +} + +function syslog_db_fetch_assoc_prepared($sql, $params = [], ...$args) { + $statement = $GLOBALS['pdo']->prepare($sql); + + try { + $statement->execute($params); + } catch (PDOException $error) { + return false; + } + + return $statement->fetchAll(PDO::FETCH_ASSOC); +} + +function syslog_db_fetch_assoc($sql, ...$args) { + return syslog_db_fetch_assoc_prepared($sql); +} + +function syslog_db_fetch_row_prepared($sql, $params = [], ...$args) { + $rows = syslog_db_fetch_assoc_prepared($sql, $params); + + return $rows ? $rows[0] : []; +} + +function syslog_db_fetch_row($sql, ...$args) { + return syslog_db_fetch_row_prepared($sql); +} + +function syslog_db_table_exists($table, ...$args) { + return (bool) syslog_db_fetch_cell_prepared('SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?', [$table]); +} + +function syslog_db_column_exists($table, $column, ...$args) { + return (bool) syslog_db_fetch_cell_prepared('SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?', [$table, $column]); +} + +function quoted_identifier($name) { + if (!preg_match('/^[A-Za-z0-9_]+$/', (string) $name)) { + throw new InvalidArgumentException('Unexpected identifier in a test statement'); + } + + return '`' . $name . '`'; +} + +function syslog_sql_save($row, $table, $key = 'id', $autoinc = true) { + if (!preg_match('/^(?:`?(\w+)`?\.)?`?(\w+)`?$/', $table, $parts)) { + throw new InvalidArgumentException('Unexpected table name in a test statement'); + } + + $target = ($parts[1] !== '' ? quoted_identifier($parts[1]) . '.' : '') . quoted_identifier($parts[2]); + $id = (int) ($row[$key] ?? 0); + + unset($row[$key]); + + $row = array_filter($row, static fn($value) => $value !== ''); + + if ($id > 0) { + $assignments = implode(', ', array_map(static fn($column) => quoted_identifier($column) . ' = ?', array_keys($row))); + + return run_sql('UPDATE ' . $target . ' SET ' . $assignments . ' WHERE ' . quoted_identifier($key) . ' = ?', array_merge(array_values($row), [$id])) ? $id : 0; + } + + $columns = implode(', ', array_map('quoted_identifier', array_keys($row))); + $marks = implode(', ', array_fill(0, count($row), '?')); + + run_sql('INSERT INTO ' . $target . ' (' . $columns . ') VALUES (' . $marks . ')', array_values($row)); + + return (int) $GLOBALS['pdo']->lastInsertId(); +} + +function db_affected_rows(...$args) { + return $GLOBALS['affected']; +} + +function syslog_db_fetch_insert_id() { + return (int) $GLOBALS['pdo']->lastInsertId(); +} + +function cacti_sizeof($items) { + return is_array($items) ? count($items) : 0; +} + +function __($text, ...$args) { + array_pop($args); + + return $args ? vsprintf($text, $args) : $text; +} + +function __esc($text, ...$args) { + return htmlspecialchars(__($text, ...$args)); +} + +function read_config_option($name) { + return $GLOBALS['options'][$name] ?? ''; +} + +function set_config_option($name, $value, $force = false) { + $GLOBALS['options'][$name] = $value; +} + +function api_plugin_hook(...$args) { +} + +function api_plugin_hook_function($hook, $data = null) { + return $data; +} + +function cacti_log(...$args) { +} + +function syslog_connect() { + return true; +} + +function syslog_dashboard_seed_default() { +} + +function db_update_table(...$args) { + return true; +} + +function syslog_db_add_column(...$args) { + return true; +} + +function title_trim($text, $length) { + return $text; +} + +function html_escape($text) { + return htmlspecialchars((string) $text); +} + +function db_qstr($value) { + return $GLOBALS['pdo']->quote((string) $value); +} + +function generate_hash() { + return bin2hex(random_bytes(16)); +} + +function cacti_escapeshellarg($value) { + return escapeshellarg((string) $value); +} + +function clean_up_name($value) { + return $value; +} + +require dirname(__DIR__, 3) . '/includes/functions.php'; +// settings.php pulls in the full plugin bootstrap, which a bare database run does not have. +$schema_dir = sys_get_temp_dir() . '/syslog-review-' . bin2hex(random_bytes(6)); +$schema_file = $schema_dir . '/schema.php'; + +mkdir($schema_dir, 0700); +file_put_contents($schema_file, str_replace("require_once(__DIR__ . '/settings.php');", '', file_get_contents(dirname(__DIR__, 3) . '/includes/schema.php'))); +chmod($schema_file, 0600); +register_shutdown_function(static function () use ($schema_file, $schema_dir) { + unlink($schema_file); + rmdir($schema_dir); +}); + +require $schema_file; +require dirname(__DIR__, 3) . '/lib/syslog_dashboard.php'; + +function table_count($table) { + $queries = [ + 'syslog' => 'SELECT COUNT(*) FROM syslog', + 'syslog_alert' => 'SELECT COUNT(*) FROM syslog_alert', + 'syslog_remove' => 'SELECT COUNT(*) FROM syslog_remove', + 'syslog_reports' => 'SELECT COUNT(*) FROM syslog_reports', + 'syslog_removed' => 'SELECT COUNT(*) FROM syslog_removed', + 'syslog_incoming' => 'SELECT COUNT(*) FROM syslog_incoming', + ]; + + return (int) $GLOBALS['pdo']->query($queries[$table])->fetchColumn(); +} + +function owner_column_type($table) { + $statement = $GLOBALS['pdo']->prepare('SELECT COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?'); + $statement->execute([$table, 'user']); + + return $statement->fetchColumn(); +} + +function set_sql_mode($mode) { + $statement = $GLOBALS['pdo']->prepare('SET SESSION sql_mode = ?'); + $statement->execute([$mode]); +} + +function db_fetch_row_prepared($sql, $params = [], ...$args) { + return syslog_db_fetch_row_prepared($sql, $params); +} + +function isset_request_var($name) { + return isset($GLOBALS['request'][$name]); +} + +function get_nfilter_request_var($name) { + return $GLOBALS['request'][$name] ?? ''; +} + +function raise_message($id, $text = '', $level = 0) { + $GLOBALS['messages'][] = [$id, $text, $level]; +} + +/** + * Load one function from a plugin page without running the page: the source is + * written to a private temporary file, required, and removed at shutdown. + */ +function extract_page_function($file, $name) { + $alias = 'page_' . preg_replace('/\W/', '_', $file) . '_' . $name; + + if (function_exists($alias)) { + return $alias; + } + + $source = file_get_contents(dirname(__DIR__, 3) . '/' . $file); + + if (!preg_match('/^function ' . preg_quote($name, '/') . '\\(.*?^\\}/ms', $source, $match)) { + throw new RuntimeException("Unable to load $file::$name"); + } + + $directory = sys_get_temp_dir() . '/syslog-page-' . bin2hex(random_bytes(6)); + $path = $directory . '/function.php'; + + mkdir($directory, 0700); + file_put_contents($path, "'; + $report_tag = true; + + return true; +} + +function db_fetch_assoc_prepared($sql, $params = [], ...$args) { + return str_contains($sql, 'plugin_realms') ? [['id' => 1]] : []; +} + +function is_realm_allowed($realm) { + return true; +} + +function get_username($user_id) { + return 'admin'; +} + +const MESSAGE_LEVEL_INFO = 1; +const MESSAGE_LEVEL_ERROR = 3; + +function fresh_database() { + $GLOBALS['pdo']->exec('DROP DATABASE IF EXISTS syslog_review_check'); + $GLOBALS['pdo']->exec('CREATE DATABASE syslog_review_check'); + $GLOBALS['pdo']->exec('USE syslog_review_check'); + $GLOBALS['pdo']->exec("SET SESSION sql_mode = ''"); +} + +function drop_database() { + $GLOBALS['pdo']->exec('DROP DATABASE IF EXISTS syslog_review_check'); +} + +function finish_checks() { + if ($GLOBALS['failures']) { + fwrite(STDERR, 'FAIL: ' . implode("\nFAIL: ", $GLOBALS['failures']) . "\n"); + + exit(1); + } + + echo 'OK: ' . $GLOBALS['checks'] . " checks passed\n"; + + exit(0); +}