' . __esc('Collector warning', 'syslog') . ': ' . html_escape($health['warning_text']) . '
'; - } break; case 'workers': diff --git a/tests/Unit/CollectorHealthTest.php b/tests/Unit/CollectorHealthTest.php index 05f0b83..3cde9ec 100644 --- a/tests/Unit/CollectorHealthTest.php +++ b/tests/Unit/CollectorHealthTest.php @@ -69,6 +69,7 @@ function collector_health_load_functions(): void { } function collector_health_setup_config(): void { + syslog_test_reset_globals(); $GLOBALS['syslogdb_default'] = 'syslogdb'; $GLOBALS['syslog_incoming_config'] = [ 'timeField' => 'logtime', @@ -268,3 +269,22 @@ function collector_health_capture_db(array &$calls, array $overrides = []): void expect(syslog_status_format_age(5400))->toContain('hour'); expect(syslog_status_format_age(90000))->toContain('day'); }); + + +it('does not warn for an empty queue after processing drains incoming messages', function () { + syslog_load_plugin_source('includes/functions.php'); + collector_health_load_functions(); + collector_health_setup_config(); + $calls = []; + collector_health_capture_db($calls, ['count' => '0']); + expect(syslog_status_collector_health()['warning'])->toBeFalse(); +}); + +it('warns about old queued messages even while new messages arrive', function () { + syslog_load_plugin_source('includes/functions.php'); + collector_health_load_functions(); + collector_health_setup_config(); + $calls = []; + collector_health_capture_db($calls, ['max' => date('Y-m-d H:i:s'), 'min' => date('Y-m-d H:i:s', time() - 600), 'count' => '2']); + expect(syslog_status_collector_health()['warning'])->toBeTrue(); +}); diff --git a/tests/Unit/DashboardDefaultTest.php b/tests/Unit/DashboardDefaultTest.php index 18e531c..0ce24b5 100644 --- a/tests/Unit/DashboardDefaultTest.php +++ b/tests/Unit/DashboardDefaultTest.php @@ -2,12 +2,10 @@ it('seeds valid starter panels atomically and does not reseed an existing default', function () { syslog_load_plugin_source('setup.php'); + syslog_load_plugin_source('includes/schema.php'); syslog_load_plugin_source('lib/syslog_dashboard.php'); - test_override('syslog_ensure_dashboard_tables', fn () => null); - test_override('syslog_db_table_exists', fn () => true); - test_override('syslog_db_column_exists', fn () => true); + test_override('db_update_table', fn () => true); $GLOBALS['syslogdb_default'] = 'syslog'; - test_override('syslog_dashboard_username', fn () => 'admin'); test_override('syslog_db_fetch_insert_id', fn () => 42); $existing = false; $calls = []; diff --git a/tests/Unit/DashboardRenderTest.php b/tests/Unit/DashboardRenderTest.php new file mode 100644 index 0000000..81aa1e5 --- /dev/null +++ b/tests/Unit/DashboardRenderTest.php @@ -0,0 +1,26 @@ + 'Never']; + $GLOBALS['request']['refresh'] = 0; + $GLOBALS['request']['dashboard_id'] = 0; + test_override('syslog_db_fetch_assoc_prepared', fn () => []); + test_override('syslog_db_fetch_assoc', fn () => []); + test_override('syslog_db_fetch_row_prepared', fn () => []); + + ob_start(); + try { + syslog_dashboard(); + $html = ob_get_contents(); + } finally { + ob_end_clean(); + } + + expect($html)->toContain('dragPanel: "Drag to reorder card"') + ->toContain('refreshPanel: "Refresh Panel"') + ->toContain("id='syslog_dashboard_grid'"); +}); diff --git a/tests/Unit/PipelineHealthTest.php b/tests/Unit/PipelineHealthTest.php new file mode 100644 index 0000000..bada38a --- /dev/null +++ b/tests/Unit/PipelineHealthTest.php @@ -0,0 +1,384 @@ + 'received_at']; + $GLOBALS['pipeline_fixture'] = [ + 'settings' => ['syslog_enabled' => 'on', 'poller_interval' => '300'], + 'status' => ['last_end_time' => (string) time()], + 'incoming' => ['pending' => '0', 'oldest' => null], + 'outbox' => ['pending' => '0', 'oldest_pending' => ''], + 'mails' => [], 'locks' => [], 'queries' => [], 'logs' => [], 'mail_error' => '', 'lock' => 1, + ]; + test_override('read_config_option', fn ($key) => $GLOBALS['pipeline_fixture']['settings'][$key] ?? ''); + test_override('syslog_db_table_exists', fn () => true); + test_override('syslog_db_fetch_assoc', function ($sql) { + $GLOBALS['pipeline_fixture']['queries'][] = $sql; + $rows = []; + foreach ($GLOBALS['pipeline_fixture']['status'] as $key => $value) { + $rows[] = ['name' => $key, 'value' => $value, 'updated' => time()]; + } + return $rows; + }); + test_override('syslog_db_fetch_row', function ($sql) { + $GLOBALS['pipeline_fixture']['queries'][] = $sql; + if (str_contains($sql, 'syslog_incoming')) return $GLOBALS['pipeline_fixture']['incoming']; + if (str_contains($sql, 'syslog_replication_output')) return $GLOBALS['pipeline_fixture']['outbox']; + return []; + }); + test_override('syslog_db_fetch_cell_prepared', function ($sql, $params) { + $GLOBALS['pipeline_fixture']['locks'][] = [$sql, $params]; + return $GLOBALS['pipeline_fixture']['lock']; + }); + test_override('syslog_db_execute_prepared', function ($sql, $params) { + if ($params[0] === 'pipeline_health_monitor') { + if (!empty($GLOBALS['pipeline_fixture']['write_failed'])) return false; + $GLOBALS['pipeline_fixture']['status'][$params[0]] = $params[1]; + } + return true; + }); + test_override('mailer', function (...$args) { + $GLOBALS['pipeline_fixture']['mails'][] = $args; + return $GLOBALS['pipeline_fixture']['mail_error']; + }); + test_override('cacti_log', function ($text) { $GLOBALS['pipeline_fixture']['logs'][] = $text; }); +} + +it('treats an empty drained queue and recent processing as healthy without writes', function () { + pipeline_health_fixture(); + $health = syslog_pipeline_health(); + expect($health['state'])->toBe('up')->and($health['issues'])->toBeEmpty() + ->and($GLOBALS['pipeline_fixture']['mails'])->toBeEmpty() + ->and($GLOBALS['__test_db_calls'])->toBeEmpty(); + expect(implode(' ', $GLOBALS['pipeline_fixture']['queries']))->toContain('MIN(`received_at`)') + ->toContain('`health_test`.`syslog_incoming`')->toContain("'partition_maintenance_blocked'"); +}); + +it('detects stalled processing even with an empty queue', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['status']['last_end_time'] = (string) (time() - 1000); + expect(syslog_pipeline_health()['issues'])->toHaveKey('processing'); +}); + +it('allows three poller intervals before declaring delayed processing', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['settings']['poller_interval'] = '600'; + $GLOBALS['pipeline_fixture']['status']['last_end_time'] = (string) (time() - 1000); + expect(syslog_pipeline_health()['issues'])->toBeEmpty(); +}); + +it('gives a new processor a grace period but detects failure to ever complete', function () { + pipeline_health_fixture(); + unset($GLOBALS['pipeline_fixture']['status']['last_end_time']); + syslog_pipeline_health_check(); + expect(syslog_pipeline_health()['issues'])->toBeEmpty(); + $GLOBALS['pipeline_fixture']['status']['pipeline_health_monitor'] = json_encode(['first_checked' => time() - 1000]); + expect(syslog_pipeline_health()['issues'])->toHaveKey('processing'); +}); + +it('detects old queued events even when fresh messages are arriving', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['incoming'] = ['pending' => 10, 'oldest' => time() - 1000]; + expect(syslog_pipeline_health()['issues'])->toHaveKey('backlog'); +}); + +it('reports queued events when the oldest message timestamp is unavailable', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['incoming'] = ['pending' => 10, 'oldest' => null]; + expect(syslog_pipeline_health()['issues']['backlog'])->toContain('timestamp is unavailable'); +}); + +it('uses the configured backlog threshold', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['settings']['syslog_backlog_threshold'] = '1000'; + $GLOBALS['pipeline_fixture']['incoming'] = ['pending' => 1001, 'oldest' => time()]; + expect(syslog_pipeline_health()['issues']['backlog'])->toContain('1,001')->toContain('1,000'); +}); + +it('never reports unavailable metrics or unsafe column mappings as healthy', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['incoming'] = false; + expect(syslog_pipeline_health()['issues']['backlog'])->toContain('unavailable'); + $GLOBALS['syslog_incoming_config']['timeField'] = 'bad`; DROP TABLE syslog'; + $GLOBALS['pipeline_fixture']['queries'] = []; + expect(syslog_pipeline_health()['issues']['backlog'])->toContain('unavailable'); + expect(implode(' ', $GLOBALS['pipeline_fixture']['queries']))->not->toContain('DROP TABLE'); +}); + +it('detects failed and delayed remote delivery without treating ordinary recovery as failure', function () { + pipeline_health_fixture(); + $GLOBALS['config']['poller_id'] = 2; + $GLOBALS['config']['connection'] = 'offline'; + $GLOBALS['pipeline_fixture']['settings']['syslog_remote_enabled'] = 'on'; + $GLOBALS['pipeline_fixture']['outbox'] = ['pending' => 10, 'oldest_pending' => date('Y-m-d H:i:s')]; + expect(syslog_pipeline_health()['issues'])->toBeEmpty(); + $GLOBALS['pipeline_fixture']['outbox']['oldest_pending'] = date('Y-m-d H:i:s', time() - 1000); + expect(syslog_pipeline_health()['issues'])->toHaveKey('replication'); + $GLOBALS['pipeline_fixture']['outbox']['oldest_pending'] = date('Y-m-d H:i:s'); + $GLOBALS['pipeline_fixture']['status']['replication_last_error'] = 'Central commit failed'; + expect(syslog_pipeline_health()['issues'])->toHaveKey('replication'); + $GLOBALS['pipeline_fixture']['outbox']['pending'] = null; + expect(syslog_pipeline_health()['issues']['replication'])->toContain('unavailable'); +}); + +it('reports queued replication records when the oldest timestamp is invalid', function () { + pipeline_health_fixture(); + $GLOBALS['config']['poller_id'] = 2; + $GLOBALS['pipeline_fixture']['settings']['syslog_remote_enabled'] = 'on'; + $GLOBALS['pipeline_fixture']['outbox'] = ['pending' => 10, 'oldest_pending' => 'not-a-timestamp']; + expect(syslog_pipeline_health()['issues']['replication'])->toContain('timestamp is unavailable'); +}); + +it('reports blocked partition maintenance with its reason', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['status']['partition_maintenance_blocked'] = '1'; + $GLOBALS['pipeline_fixture']['status']['partition_maintenance_reason'] = 'DDL failed'; + expect(syslog_pipeline_health()['issues']['partition'])->toContain('DDL failed'); +}); + +it('sends one problem notification and one recovery notification without duplicate polling emails', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['settings']['syslog_pipeline_health_email'] = 'ops@example.test'; + $GLOBALS['pipeline_fixture']['incoming'] = ['pending' => 20000, 'oldest' => time()]; + syslog_pipeline_health_check(); + syslog_pipeline_health_check(); + expect($GLOBALS['pipeline_fixture']['mails'])->toHaveCount(1); + $monitor = json_decode($GLOBALS['pipeline_fixture']['status']['pipeline_health_monitor'], true); + $monitor['last_attempt'] -= 301; + $GLOBALS['pipeline_fixture']['status']['pipeline_health_monitor'] = json_encode($monitor); + $GLOBALS['pipeline_fixture']['incoming']['pending'] = 0; + syslog_pipeline_health_check(); + syslog_pipeline_health_check(); + expect($GLOBALS['pipeline_fixture']['mails'])->toHaveCount(2) + ->and($GLOBALS['pipeline_fixture']['mails'][1][7])->toContain('recovered'); + expect($GLOBALS['pipeline_fixture']['locks'][1][0])->toContain('RELEASE_LOCK'); +}); + +it('retries failed mail after five minutes without claiming success', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['settings']['syslog_pipeline_health_email'] = 'ops@example.test'; + $GLOBALS['pipeline_fixture']['incoming'] = ['pending' => 20000, 'oldest' => time()]; + $GLOBALS['pipeline_fixture']['mail_error'] = 'SMTP unavailable'; + syslog_pipeline_health_check(); + syslog_pipeline_health_check(); + $monitor = json_decode($GLOBALS['pipeline_fixture']['status']['pipeline_health_monitor'], true); + expect($monitor['notification'])->toBe('failed')->and($monitor)->not->toHaveKey('last_notified') + ->and($GLOBALS['pipeline_fixture']['mails'])->toHaveCount(1); + $monitor['last_attempt'] -= 301; + $GLOBALS['pipeline_fixture']['status']['pipeline_health_monitor'] = json_encode($monitor); + $GLOBALS['pipeline_fixture']['mail_error'] = ''; + syslog_pipeline_health_check(); + expect($GLOBALS['pipeline_fixture']['mails'])->toHaveCount(2); + expect(json_decode($GLOBALS['pipeline_fixture']['status']['pipeline_health_monitor'], true)['notification'])->toBe('sent'); +}); + +it('reminds for ongoing problems only after the configured interval', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['settings'] += ['syslog_pipeline_health_email' => 'ops@example.test', 'syslog_pipeline_health_repeat' => '900']; + $GLOBALS['pipeline_fixture']['incoming'] = ['pending' => 20000, 'oldest' => time()]; + syslog_pipeline_health_check(); + $monitor = json_decode($GLOBALS['pipeline_fixture']['status']['pipeline_health_monitor'], true); + $monitor['last_attempt'] -= 901; + $monitor['last_notified'] -= 901; + $GLOBALS['pipeline_fixture']['status']['pipeline_health_monitor'] = json_encode($monitor); + syslog_pipeline_health_check(); + expect($GLOBALS['pipeline_fixture']['mails'])->toHaveCount(2); +}); + +it('keeps a due notification pending while the retry cooldown is active', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['settings']['syslog_pipeline_health_email'] = 'ops@example.test'; + $GLOBALS['pipeline_fixture']['incoming']['pending'] = 20000; + $GLOBALS['pipeline_fixture']['status']['pipeline_health_monitor'] = json_encode([ + 'signature' => 'up:', + 'notified_signature' => 'previous:issue', + 'last_attempt' => time(), + 'notification' => 'ready', + ]); + + syslog_pipeline_health_check(); + + $monitor = json_decode($GLOBALS['pipeline_fixture']['status']['pipeline_health_monitor'], true); + expect($monitor['notification'])->toBe('pending') + ->and($GLOBALS['pipeline_fixture']['mails'])->toBeEmpty(); +}); + +it('checks and logs health with email disabled', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['incoming'] = ['pending' => 20000, 'oldest' => time()]; + syslog_pipeline_health_check(); + syslog_pipeline_health_check(); + expect($GLOBALS['pipeline_fixture']['logs'])->toHaveCount(1) + ->and($GLOBALS['pipeline_fixture']['mails'])->toBeEmpty(); +}); + +it('does not alert for intentionally disabled processing and gives re-enablement a grace period', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['settings'] += ['syslog_pipeline_health_email' => 'ops@example.test']; + $GLOBALS['pipeline_fixture']['settings']['syslog_enabled'] = ''; + $GLOBALS['pipeline_fixture']['status']['last_end_time'] = (string) (time() - 1000); + syslog_pipeline_health_check(); + expect(syslog_pipeline_health()['state'])->toBe('disabled')->and($GLOBALS['pipeline_fixture']['mails'])->toBeEmpty(); + $GLOBALS['pipeline_fixture']['settings']['syslog_enabled'] = 'on'; + syslog_pipeline_health_check(); + expect(syslog_pipeline_health()['issues'])->toBeEmpty(); +}); + +it('skips notifications when another monitor owns the lock', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['lock'] = 0; + syslog_pipeline_health_check(); + expect($GLOBALS['pipeline_fixture']['queries'])->toBeEmpty()->and($GLOBALS['pipeline_fixture']['mails'])->toBeEmpty(); +}); + +it('runs the monitor from the independent poller before launching the processor', function () { + $source = plugin_test_read_source('includes/processing.php'); + expect(strpos($source, 'syslog_pipeline_health_check();'))->toBeLessThan(strpos($source, 'exec_background($command_string, $extra_args);')); +}); + + +it('does not send email when the retry state cannot be persisted', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['settings']['syslog_pipeline_health_email'] = 'ops@example.test'; + $GLOBALS['pipeline_fixture']['incoming']['pending'] = 20000; + $GLOBALS['pipeline_fixture']['write_failed'] = true; + syslog_pipeline_health_check(); + expect($GLOBALS['pipeline_fixture']['mails'])->toBeEmpty() + ->and($GLOBALS['pipeline_fixture']['locks'][1][0])->toContain('RELEASE_LOCK'); +}); + +it('keeps mail exceptions from blocking processing and releases the monitor lock', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['settings']['syslog_pipeline_health_email'] = 'ops@example.test'; + $GLOBALS['pipeline_fixture']['incoming']['pending'] = 20000; + test_override('mailer', function () { throw new RuntimeException('Mail transport failed'); }); + syslog_pipeline_health_check(); + expect(json_decode($GLOBALS['pipeline_fixture']['status']['pipeline_health_monitor'], true)['notification'])->toBe('failed') + ->and($GLOBALS['pipeline_fixture']['locks'][1][0])->toContain('RELEASE_LOCK'); +}); + +it('renders pipeline problems and notification status in the existing Status card with escaped details', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['status']['partition_maintenance_blocked'] = '1'; + $GLOBALS['pipeline_fixture']['status']['partition_maintenance_reason'] = ''; + $GLOBALS['pipeline_fixture']['status']['pipeline_health_monitor'] = json_encode(['checked' => time(), 'notification' => 'failed']); + $source = plugin_test_read_source('syslog.php'); + foreach (['syslog_status_data', 'syslog_status_workers', 'syslog_status_pipeline', 'syslog_status_card_meta', 'syslog_status_card_body', 'syslog_status_collector_health', 'syslog_status_collector_unavailable', 'syslog_status_format_age', 'syslog_status_format_count', 'syslog_status_format_time'] as $name) { + if (!function_exists($name)) { + $start = strpos($source, "function $name("); + $end = strpos($source, "\n}", $start); + eval(substr($source, $start, $end - $start + 2)); + } + } + if (!defined('SYSLOG_COLLECTOR_STALE_SECONDS')) define('SYSLOG_COLLECTOR_STALE_SECONDS', 300); + if (!defined('SYSLOG_COLLECTOR_BACKLOG_THRESHOLD')) define('SYSLOG_COLLECTOR_BACKLOG_THRESHOLD', 10000); + $html = syslog_status_card_body('collector_health'); + expect(syslog_status_card_meta()['collector_health']['title'])->toBe('Pipeline health') + ->and(syslog_status_card_meta()['collector_health']['state'])->toBe('down') + ->and($html)->toContain('Attention required')->toContain('Partition maintenance') + ->toContain('Failed; retry pending')->toContain('Last health check') + ->toContain('<script>')->not->toContain('