diff --git a/CHANGELOG.md b/CHANGELOG.md index 52c9a69..e1f5089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ * issue: Look up the panel row before building the details view, so an unknown panel id no longer logs a warning or creates a row * dev: Add end-to-end tests that log in to a running Cacti and drive intropage.php, the poller, the graph_buttons hook and the upgrade, and run them in CI against the installed Cacti served by PHP's built-in server * dev: Add tests/bin/e2e-coverage-report.php, which lists the tests that execute each changed line from the end-to-end Xdebug dumps and the unit XML coverage +* issue: Query NTP servers over IPv6 as well as IPv4: an IPv6 address (also in brackets) is used as it is, a name is resolved to its first AAAA and A address and both are tried; the check no longer needs the PHP sockets extension +* issue: Fix the first favourite graph of a user without panels not being stored (the priority came out as NULL) +* issue: `intropage_add_panel()` and `intropage_remove_panel()` could not work against the current schema; they now log a deprecation warning and return false. Register panels with a register_() function in a panellib file +* issue: Stop the poller running panel code, and Cacti logging an authentication error each cycle, for panel rows of users that no longer exist; the rows and their dashboard links are deleted in bounded steps * issue: Fix the dashboard page not scrolling with the mouse wheel over chart panels: charts were sized from the whole card (header included) and overflowed the card body, turning it into a nested scroller that captured the wheel; charts now keep their server-sized height (which also stops pie/treemap charts growing on every window resize) and card bodies no longer scroll vertically * issue: Keep long Panel Details content inside the dialog and scrollable, instead of spilling under the Close button pane, on themes (e.g. deepness) that force `.ui-dialog-content` to `overflow: visible`; the dialog height is also capped to the viewport * issue: Render dashboard panels with a shared, self-contained card chrome (bundled `includes/cards/cards.css` + `cards.js`, no cross-plugin dependency): a left-hand drag handle and unified per-card tools (grow/shrink height, maximize, reload, remove), replacing jQuery UI Sortable with a keyboard-accessible drag; growing/shrinking a panel now resizes it immediately before the persist+reload round-trip diff --git a/includes/functions.php b/includes/functions.php index aeb48b9..758adb6 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -501,7 +501,7 @@ function intropage_actions(): void { $span = $_SESSION['sess_current_timespan']; } - $prio = db_fetch_cell_prepared('SELECT MAX(priority) + 1 + $prio = db_fetch_cell_prepared('SELECT COALESCE(MAX(priority), 0) + 1 FROM plugin_intropage_panel_data WHERE user_id = ?', [$_SESSION['sess_user_id']]); @@ -1541,6 +1541,56 @@ function intropage_poller_impersonate($user_id): int { return $user_id; } +/** + * Selects the panel data rows the poller has to refresh: those whose + * refresh interval has passed and system panels that have no row yet. Rows + * of users that no longer exist are left out, so the poller does not run + * panel code for them (Cacti logs an authentication error for each). + * + * @return array The due rows. + */ +function intropage_poller_due_panels(): array { + return db_fetch_assoc('SELECT pd.panel_id, pd.name, ud.id, ud.user_id, level, + pd.refresh, ud.refresh_interval, pd.update_func + FROM plugin_intropage_panel_definition AS pd + LEFT JOIN plugin_intropage_panel_data AS ud + ON pd.panel_id = ud.panel_id + LEFT JOIN user_auth AS ua + ON ua.id = ud.user_id + WHERE (UNIX_TIMESTAMP(last_update) < UNIX_TIMESTAMP() - refresh_interval + OR (last_update IS NULL AND level = 0)) + AND (ud.user_id IS NULL OR ud.user_id = 0 OR ua.id IS NOT NULL)'); +} + +/** + * Deletes dashboard links and panel data rows of users that no longer + * exist, a bounded number per call. The user_remove hook does this when a + * user is deleted while the plugin is active; rows are left behind when it + * was not. System rows (user 0) are never touched, and nothing is deleted + * when the user table cannot be read or is empty. + * + * @param int $limit The most rows deleted from each table per call. + * + * @return void + */ +function intropage_purge_orphan_rows(int $limit = 500): void { + if ((int) db_fetch_cell('SELECT COUNT(*) FROM user_auth') === 0) { + return; + } + + $limit = max(1, $limit); + + db_execute('DELETE FROM plugin_intropage_panel_dashboard + WHERE user_id > 0 + AND user_id NOT IN (SELECT id FROM user_auth) + LIMIT ' . $limit); + + db_execute('DELETE FROM plugin_intropage_panel_data + WHERE user_id > 0 + AND user_id NOT IN (SELECT id FROM user_auth) + LIMIT ' . $limit); +} + /** * Persists a panel's freshly computed data/alarm state back to its * plugin_intropage_panel_data row and updates its last-update @@ -2388,86 +2438,154 @@ function intropage_addpanel_select(int $dashboard_id): void { } /** - * Queries a remote NTP server via a raw UDP NTP request and returns the - * server's reported Unix timestamp, used to detect local clock drift. - * Called from ntp_dns() (panellib/misc.php) for the 'NTP/DNS Status' - * panel. + * Turns DNS records into the addresses they carry. * - * @param string $host The NTP server hostname/IP to query. + * @param array $records Records as returned by dns_get_record(); AAAA + * records carry 'ipv6', A records carry 'ip'. * - * @return int|string The remote Unix timestamp, or an 'error: ...' - * string describing a socket failure. + * @return string[] The valid addresses, in the order of the records. + */ +function intropage_ntp_record_addresses(array $records): array { + $addresses = []; + + foreach ($records as $record) { + $address = $record['ipv6'] ?? $record['ip'] ?? ''; + + if (filter_var($address, FILTER_VALIDATE_IP) !== false) { + $addresses[] = $address; + } + } + + return $addresses; +} + +/** + * Resolves a host name to its IPv6 and IPv4 addresses, IPv6 first. The + * system resolver is asked as well, so names that only exist in the hosts + * file keep working. * - * @global array $config Cacti global configuration array; used to - * select the correct socket receive mode on - * Windows vs. other platforms. + * @param string $host The host name. + * + * @return string[] The addresses, empty when the name does not resolve. */ -function ntp_time(string $host) { - global $config; +function intropage_ntp_lookup(string $host): array { + $aaaa = @dns_get_record($host, DNS_AAAA); + $a = @dns_get_record($host, DNS_A); + $local = gethostbyname($host); + + return array_values(array_unique(array_merge( + intropage_ntp_record_addresses(is_array($aaaa) ? $aaaa : []), + intropage_ntp_record_addresses(is_array($a) ? $a : []), + filter_var($local, FILTER_VALIDATE_IP) !== false ? [$local] : [] + ))); +} - $timestamp = -1; - // create a UDP socket - $sock = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP); +/** + * Picks the addresses to ask for the time. An IP address, with or without + * the brackets IPv6 literals are written with, is used as it is. A name + * gives its first IPv6 and its first IPv4 address, in that order, so a + * server that is only reachable over one family costs one timeout. + * + * @param string $host The NTP server setting. + * @param callable|null $lookup Resolver taking a name and returning + * addresses; defaults to intropage_ntp_lookup(). + * + * @return string[] The addresses to try, empty when the name does not resolve. + */ +function intropage_ntp_addresses(string $host, ?callable $lookup = null): array { + $host = trim($host, " \t[]"); - if (!$sock) { - return 'error: socket_create failed:' . socket_strerror(socket_last_error()); + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + return [$host]; } - // set a timeout of 5 second - $timeout = ['sec' => 5, 'usec' => 0]; - socket_set_option($sock, SOL_SOCKET, SO_RCVTIMEO, $timeout); + $lookup ??= 'intropage_ntp_lookup'; - // clear any existing error - socket_clear_error(); + $found = array_values($lookup($host)); + $ipv6 = array_values(array_filter($found, static fn ($address) => strpos($address, ':') !== false)); + $ipv4 = array_values(array_diff($found, $ipv6)); - // send an NTP request to the server - $request = "\x1b" . str_repeat("\x00", 47); + return array_merge(array_slice($ipv6, 0, 1), array_slice($ipv4, 0, 1)); +} - if (socket_sendto($sock, $request, strlen($request), 0, $host, 123) === false) { - $error = socket_strerror(socket_last_error($sock)); - socket_close($sock); +/** + * Asks one address for the time with an NTP request over UDP. + * + * @param string $address IPv4 or IPv6 address, without brackets. + * @param int $port UDP port of the server. + * @param int $timeout Seconds to wait for the answer. + * + * @return int|string The remote Unix timestamp, or an 'error: ...' string. + */ +function intropage_ntp_query(string $address, int $port = 123, int $timeout = 5) { + $target = filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false ? '[' . $address . ']' : $address; + + // only an address is opened: a name here would be resolved by the socket call, without the IPv6 first order + $errstr = 'not an IP address'; + $sock = filter_var($address, FILTER_VALIDATE_IP) === false ? false : @stream_socket_client('udp://' . $target . ':' . $port, $errno, $errstr, $timeout); - return 'error: socket_sendto failed:' . $error; + if ($sock === false) { + return 'error: connect to ' . $address . ' failed: ' . $errstr; } - // receive the NTP response from the server - $recv = ''; + stream_set_timeout($sock, $timeout); - if ($config['cacti_server_os'] == 'win32') { - if (socket_recv($sock, $recv, 48, MSG_PEEK) === false) { - $error = socket_strerror(socket_last_error($sock)); - socket_close($sock); + // the client request: version 3, mode 3, the other 47 bytes are zero + fwrite($sock, "\x1b" . str_repeat("\x00", 47)); - return 'error: socket_recv failed: ' . $error; - } - } else { - if (socket_recv($sock, $recv, 48, MSG_WAITALL) === false) { - $error = socket_strerror(socket_last_error($sock)); - socket_close($sock); + $recv = fread($sock, 48); + $meta = stream_get_meta_data($sock); - return 'error: socket_recv failed: ' . $error; - } + fclose($sock); + + if ($meta['timed_out']) { + return 'error: no answer from ' . $address . ' within ' . $timeout . ' seconds'; } - // extract the timestamp from the received data + + if (!is_string($recv) || strlen($recv) < 48) { + return 'error: short answer from ' . $address; + } + $data = unpack('N12', $recv); - if (!is_array($data)) { - socket_close($sock); + // the transmit timestamp is the ninth 32 bit word; NTP counts seconds from 1900, Unix time from 1970 + return (int) ($data[9] - 2208988800); +} + +/** + * Queries an NTP server and returns the server's Unix timestamp, used to + * detect local clock drift. The server may be an IPv4 or IPv6 address, the + * latter also in brackets, or a name. Called from ntp_dns() + * (panellib/misc.php) for the 'NTP/DNS Status' panel. + * + * @param string $host The NTP server setting. + * @param int $port UDP port of the server. + * @param int $timeout Seconds to wait for each address. + * @param callable|null $lookup Name resolver, see intropage_ntp_addresses(). + * + * @return int|string The remote Unix timestamp, or an 'error: ...' string + * describing the last failure. + */ +function ntp_time(string $host, int $port = 123, int $timeout = 5, ?callable $lookup = null) { + $addresses = intropage_ntp_addresses($host, $lookup); - return 'error: unpack failed'; + if ($addresses === []) { + return 'error: unable to resolve ' . trim($host); } - $timestamp = sprintf('%u', $data[9]); + $result = ''; - // close the socket - socket_close($sock); + foreach ($addresses as $address) { + $result = intropage_ntp_query($address, $port, $timeout); - // NTP is number of seconds since 0000 UT on 1 January 1900 - // Unix time is seconds since 0000 UT on 1 January 1970 - $timestamp -= 2208988800; + if (is_int($result)) { + return $result; + } + } - return (int) $timestamp; + return $result; } + /** * Graph_buttons/graph_buttons_thumbnails hook: adds an 'Add to * Favorites/Intropage' button to Cacti's graph view toolbar, letting diff --git a/poller_intropage.php b/poller_intropage.php index 179efc1..a00149d 100644 --- a/poller_intropage.php +++ b/poller_intropage.php @@ -189,13 +189,9 @@ function intropage_gather_stats(): array { $panels = initialize_panel_library(); - $upanels = db_fetch_assoc('SELECT pd.panel_id, pd.name, ud.id, ud.user_id, level, - pd.refresh, ud.refresh_interval, pd.update_func - FROM plugin_intropage_panel_definition AS pd - LEFT JOIN plugin_intropage_panel_data AS ud - ON pd.panel_id = ud.panel_id - WHERE UNIX_TIMESTAMP(last_update) < UNIX_TIMESTAMP() - refresh_interval - OR (last_update IS NULL AND level = 0)'); + intropage_purge_orphan_rows(); + + $upanels = intropage_poller_due_panels(); $tpanels = db_fetch_assoc('SELECT pd.panel_id, pd.name, ud.id, ud.user_id, level, pd.refresh, ud.trend_interval, ud.refresh_interval, pd.trends_func diff --git a/setup.php b/setup.php index c4dde5d..19b074b 100644 --- a/setup.php +++ b/setup.php @@ -543,67 +543,48 @@ function intropage_poller_bottom(): void { exec_background($command_string, $extra_args); } -// add third party panel: -// 1) include this file -// 2) call intropage_add_panel('my_panel','/plugins/your_plugin/file.php','yes',3600,20) { -// panel_id - your name (lowercase, without spaces, unique) -// file - path to your code. It must contain function my_panel() (and my_panel_detail() if your panel has detail) -// example functions are in /plugin/intropage/include/data.php and data_detail.php -// has_detail - yes or no -// refresh_interval - in second, min is 60 -// priority - for displaying -// description - small description, it is visible in user auth settings +// Third party panels are not added through this file. Register them with a +// register_() function in a panellib file, see panellib/README.md. +// The two functions below are kept so that a plugin that still calls them +// gets a clear message in the Cacti log instead of a fatal error. /** - * Public extension API: registers a third-party dashboard panel by - * inserting its definition and adding a corresponding per-user - * visibility column to the user-auth table. Intended to be called by - * other plugins wishing to add their own Intropage panel. - * - * @param string $panel_id A unique, lowercase, space-free - * identifier for the panel. - * @param string $file Path to the file containing the - * panel's rendering function(s). - * @param string $has_detail 'yes' or 'no', whether the panel has - * a detail view. - * @param int $refresh_interval The panel's refresh interval in - * seconds (minimum 60). - * @param int $priority The panel's display priority/order. - * @param string $description A short description shown in user - * auth settings. - * - * @return string '1' on success, or the database error string on - * failure. + * Deprecated. This registered a third-party panel in columns that + * plugin_intropage_panel_definition and plugin_intropage_user_auth no + * longer have, so it could not work. It logs a warning and does nothing. + * + * @deprecated Register panels with a register_() function in a + * panellib file, see panellib/README.md. + * + * @param string $panel_id Ignored, named in the log message. + * @param string $file Ignored. + * @param string $has_detail Ignored. + * @param int $refresh_interval Ignored. + * @param int $priority Ignored. + * @param string $description Ignored. + * + * @return bool Always false. */ -function intropage_add_panel(string $panel_id, string $file, string $has_detail, int $refresh_interval, int $priority = 20, string $description = ''): string { - if (db_execute_prepared('REPLACE INTO plugin_intropage_panel_definition - (panel_id,file,has_detail,refresh_interval, priority, description) - VALUES (?,?,?,?,?,?)', [$panel_id, $file, $has_detail, $refresh_interval, $priority, $description]) == 1) { - api_plugin_db_add_column('intropage', 'plugin_intropage_user_auth', ['name' => $panel_id, 'type' => 'char(2)', 'NULL' => false, 'default' => 'on']); - - return ('1'); - } else { - return db_error(); - } +function intropage_add_panel(string $panel_id, string $file, string $has_detail, int $refresh_interval, int $priority = 20, string $description = ''): bool { + cacti_log('WARNING: intropage_add_panel() is deprecated and does nothing; panel ' . $panel_id . ' was not added. Register panels with a register_() function in a panellib file, see plugins/intropage/panellib/README.md', false, 'INTROPAGE'); + + return false; } -// remove third party panel /** - * Public extension API: unregisters a third-party dashboard panel, - * removing its stored data, definition, and per-user visibility column. - * Intended to be called by other plugins removing their own Intropage - * panel. + * Deprecated. This removed a third-party panel with SQL that could not + * run against the current schema. It logs a warning and does nothing. + * + * @deprecated Panels come from the panellib files; remove the file or the + * entry in its register_() function. * - * @param string $panel_id The panel identifier previously registered - * via intropage_add_panel(). + * @param string $panel_id Ignored, named in the log message. * - * @return string Always '1'. + * @return bool Always false. */ -function intropage_remove_panel(string $panel_id): string { - db_execute_prepared('DELETE FROM plugin_intropage_panel_data WHERE panel_id = ?', [$panel_id]); - db_execute_prepared('DELETE FROM plugin_intropage_panel_definition WHERE panel_id = ?', [$panel_id]); - db_execute_prepared('ALTER TABLE plugin_intropage_user_auth DROP ?',[$panel_id]); +function intropage_remove_panel(string $panel_id): bool { + cacti_log('WARNING: intropage_remove_panel() is deprecated and does nothing; panel ' . $panel_id . ' was not removed. Remove it from the register_() function of its panellib file', false, 'INTROPAGE'); - return ('1'); + return false; } /** diff --git a/tests/Integration/IntropageHttpTest.php b/tests/Integration/IntropageHttpTest.php index f6d85b1..e3451bf 100644 --- a/tests/Integration/IntropageHttpTest.php +++ b/tests/Integration/IntropageHttpTest.php @@ -1111,18 +1111,23 @@ function intropage_e2e_probe(string $probe, int $user, int $row): string { expect((int) intropage_e2e_cell('SELECT user_id FROM plugin_intropage_panel_data WHERE id = ?', [$row]))->toBe($user); }); - it('processes the panel row of a user that no longer exists without failing', function () { + it('skips and removes the rows of a user that no longer exists', function () { intropage_e2e_exec('INSERT INTO plugin_intropage_panel_data (panel_id, user_id, priority, alarm, refresh_interval, trend_interval, height, last_update, last_trend_update) VALUES (?, ?, 30, ?, 300, 300, ?, ?, NOW())', ['alert_host', 987654, 'green', 'normal', '2000-01-01 00:00:00']); - // Cacti logs an authentication error for the missing user on each cycle - intropage_e2e_expect_log('Invalid Cacti User ID 987654'); + $orphan = (int) intropage_e2e_cell('SELECT MAX(id) FROM plugin_intropage_panel_data WHERE user_id = ?', [987654]); + $kept = intropage_e2e_seed_panel('e2e_viewer', 'alert_host'); - $run = intropage_e2e_poller(); + intropage_e2e_exec('INSERT INTO plugin_intropage_panel_dashboard (panel_id, user_id, dashboard_id) VALUES (?, ?, 1)', [$orphan, 987654]); + intropage_e2e_exec('UPDATE plugin_intropage_panel_data SET last_update = ? WHERE id = ?', ['2000-01-01 00:00:00', $kept]); - intropage_e2e_exec('DELETE FROM plugin_intropage_panel_data WHERE user_id = 987654'); + // Cacti would log "Invalid Cacti User ID 987654" for each cycle if the row were processed; the log check in afterEach fails on it + $run = intropage_e2e_poller(); expect($run['output'])->not->toContain('TypeError'); expect($run['status'])->toBe(0); + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_panel_data WHERE user_id = ?', [987654]))->toBe(0); + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_panel_dashboard WHERE user_id = ?', [987654]))->toBe(0); + expect(intropage_e2e_cell('SELECT last_update FROM plugin_intropage_panel_data WHERE id = ?', [$kept]))->not->toBe('2000-01-01 00:00:00'); }); it('does nothing for a panel that is not due', function () { @@ -1198,9 +1203,7 @@ function intropage_e2e_probe(string $probe, int $user, int $row): string { $client = intropage_e2e_client('e2e_viewer'); $user = intropage_e2e_user_id('e2e_viewer'); - // The priority of a new favourite is the highest existing one plus one, so the user needs a panel already - intropage_e2e_seed_panel('e2e_viewer', 'alert_host'); - + // The user has no panel rows: the first favourite gets priority 1 // Cacti's graph pages normally keep the timespan in the session; without it the plugin falls back to its default intropage_e2e_expect_log('sess_current_timespan'); @@ -1220,7 +1223,6 @@ function intropage_e2e_probe(string $probe, int $user, int $row): string { it('does not store a favourite graph for a user without the favourite graph permission', function () { [, $graph] = intropage_e2e_seed_graph('e2e favourite graph'); - intropage_e2e_seed_panel('e2e_viewer', 'alert_host'); intropage_e2e_set_permissions('e2e_viewer', ['alert_host']); intropage_e2e_expect_log('sess_current_timespan'); diff --git a/tests/Security/SetupStructureTest.php b/tests/Security/SetupStructureTest.php index 69b5968..973bcb8 100644 --- a/tests/Security/SetupStructureTest.php +++ b/tests/Security/SetupStructureTest.php @@ -22,8 +22,10 @@ expect($source)->toContain('function plugin_intropage_uninstall'); }); - it('returns version array with name key', function () use ($source) { - expect($source)->toMatch('/[\'\""]name[\'\""]\s*=>/'); + it('returns version array with name key', function () { + require_once __DIR__ . '/../../setup.php'; + + expect(plugin_intropage_version())->toHaveKey('name', 'intropage'); }); it('exposes the plugin version from the INFO file', function () use ($source) { diff --git a/tests/Unit/IntropageNtpTest.php b/tests/Unit/IntropageNtpTest.php new file mode 100644 index 0000000..96c27be --- /dev/null +++ b/tests/Unit/IntropageNtpTest.php @@ -0,0 +1,236 @@ + ['pipe', 'w']], $pipes); + $line = trim((string) fgets($pipes[1])); + + if (!is_numeric($line)) { + proc_terminate($process); + proc_close($process); + unlink($script); + + return null; + } + + return ['process' => $process, 'pipes' => $pipes, 'port' => (int) $line, 'script' => $script]; +} + +function intropage_ntp_server_stop(array $server): void { + proc_terminate($server['process']); + proc_close($server['process']); + unlink($server['script']); +} + +describe('intropage_ntp_record_addresses', function () { + it('reads the address from A and AAAA records', function () { + $records = [ + ['host' => 'ntp.test', 'type' => 'A', 'ip' => '192.0.2.10'], + ['host' => 'ntp.test', 'type' => 'AAAA', 'ipv6' => '2001:db8::10'], + ]; + + expect(intropage_ntp_record_addresses($records))->toBe(['192.0.2.10', '2001:db8::10']); + }); + + it('skips records without a valid address', function () { + expect(intropage_ntp_record_addresses([['type' => 'CNAME', 'target' => 'a.test'], ['ip' => 'not-an-ip'], []]))->toBe([]); + }); +}); + +describe('intropage_ntp_addresses', function () { + it('uses an address as it is', function (string $setting, string $expected) { + $lookup = static function (string $host): array { + throw new RuntimeException("no lookup expected for $host"); + }; + + expect(intropage_ntp_addresses($setting, $lookup))->toBe([$expected]); + })->with([ + 'IPv4' => ['192.0.2.7', '192.0.2.7'], + 'IPv4 with spaces' => [' 192.0.2.7 ', '192.0.2.7'], + 'IPv6' => ['2001:db8:0:0:0:0:0:1', '2001:db8:0:0:0:0:0:1'], + 'IPv6 compressed' => ['2001:db8::1', '2001:db8::1'], + 'IPv6 loopback' => ['::1', '::1'], + 'IPv6 in brackets' => ['[2001:db8::1]', '2001:db8::1'], + 'IPv6 in brackets, set' => [' [::1] ', '::1'], + ]); + + it('resolves a name with only an A record', function () { + expect(intropage_ntp_addresses('ntp.test', static fn () => ['192.0.2.1', '192.0.2.2']))->toBe(['192.0.2.1']); + }); + + it('resolves a name with only an AAAA record', function () { + expect(intropage_ntp_addresses('ntp.test', static fn () => ['2001:db8::1', '2001:db8::2']))->toBe(['2001:db8::1']); + }); + + it('tries the IPv6 address before the IPv4 address for a name with both', function () { + $lookup = static fn () => ['192.0.2.1', '2001:db8::1', '192.0.2.2', '2001:db8::2']; + + expect(intropage_ntp_addresses('ntp.test', $lookup))->toBe(['2001:db8::1', '192.0.2.1']); + }); + + it('returns nothing for a name that does not resolve', function () { + expect(intropage_ntp_addresses('missing.test', static fn () => []))->toBe([]); + }); + + it('resolves a name through the system resolver by default', function () { + expect(intropage_ntp_addresses('localhost'))->toContain('127.0.0.1'); + }); +}); + +describe('intropage_ntp_lookup', function () { + it('finds localhost from the hosts file', function () { + expect(intropage_ntp_lookup('localhost'))->toContain('127.0.0.1'); + }); + + it('returns an empty list for a name that cannot be resolved', function () { + expect(intropage_ntp_lookup('name-that-does-not-exist.invalid'))->toBe([]); + }); +}); + +describe('ntp_time', function () { + afterEach(function () { + foreach ($this->servers ?? [] as $server) { + intropage_ntp_server_stop($server); + } + }); + + it('reads the time from an IPv4 server', function () { + $this->servers = [$server = intropage_ntp_server('udp://127.0.0.1:0', 'reply', 1700000000 + INTROPAGE_NTP_OFFSET)]; + + expect(ntp_time('127.0.0.1', $server['port'], 5))->toBe(1700000000); + }); + + it('reads the time from an IPv6 server given as a compressed address', function () { + $server = intropage_ntp_server('udp://[::1]:0', 'reply', 1700000001 + INTROPAGE_NTP_OFFSET); + + if ($server === null) { + $this->markTestSkipped('No IPv6 loopback address in this environment.'); + } + + $this->servers = [$server]; + + expect(ntp_time('::1', $server['port'], 5))->toBe(1700000001); + }); + + it('reads the time from an IPv6 server given in brackets', function () { + $server = intropage_ntp_server('udp://[::1]:0', 'reply', 1700000002 + INTROPAGE_NTP_OFFSET); + + if ($server === null) { + $this->markTestSkipped('No IPv6 loopback address in this environment.'); + } + + $this->servers = [$server]; + + expect(ntp_time('[::1]', $server['port'], 5))->toBe(1700000002); + }); + + it('uses the IPv4 address of a name that only has an A record', function () { + $this->servers = [$server = intropage_ntp_server('udp://127.0.0.1:0', 'reply', 1700000003 + INTROPAGE_NTP_OFFSET)]; + + expect(ntp_time('ntp.test', $server['port'], 5, static fn () => ['127.0.0.1']))->toBe(1700000003); + }); + + it('uses the IPv6 address of a name that only has an AAAA record', function () { + $server = intropage_ntp_server('udp://[::1]:0', 'reply', 1700000004 + INTROPAGE_NTP_OFFSET); + + if ($server === null) { + $this->markTestSkipped('No IPv6 loopback address in this environment.'); + } + + $this->servers = [$server]; + + expect(ntp_time('ntp.test', $server['port'], 5, static fn () => ['::1']))->toBe(1700000004); + }); + + it('falls back to the IPv4 address when the IPv6 address does not answer', function () { + $this->servers = [$server = intropage_ntp_server('udp://127.0.0.1:0', 'reply', 1700000005 + INTROPAGE_NTP_OFFSET)]; + + // Nothing listens on this port on ::1, so the first attempt fails and the second succeeds + expect(ntp_time('ntp.test', $server['port'], 2, static fn () => ['127.0.0.1', '::1']))->toBe(1700000005); + }); + + it('reports a name that does not resolve', function () { + expect(ntp_time('missing.test', 123, 1, static fn () => []))->toBe('error: unable to resolve missing.test'); + }); + + it('reports a server that does not answer within the timeout', function () { + $this->servers = [$server = intropage_ntp_server('udp://127.0.0.1:0', 'silent', 0)]; + + $start = microtime(true); + $result = ntp_time('127.0.0.1', $server['port'], 1); + + expect($result)->toBe('error: no answer from 127.0.0.1 within 1 seconds'); + expect(microtime(true) - $start)->toBeGreaterThanOrEqual(0.9); + }); + + it('reports an answer that is too short', function () { + $this->servers = [$server = intropage_ntp_server('udp://127.0.0.1:0', 'short', 0)]; + + expect(ntp_time('127.0.0.1', $server['port'], 5))->toBe('error: short answer from 127.0.0.1'); + }); + + it('does not open a socket for something that is not an address', function () { + expect(intropage_ntp_query('not-an-address', 123, 1))->toBe('error: connect to not-an-address failed: not an IP address'); + }); + + it('returns the error of the last address when every address fails', function () { + $result = ntp_time('ntp.test', 9, 1, static fn () => ['::zz', 'not-ip']); + + expect($result)->toBe('error: connect to not-ip failed: not an IP address'); + }); +}); diff --git a/tests/Unit/IntropagePanelHelpersTest.php b/tests/Unit/IntropagePanelHelpersTest.php index 63eac59..9003093 100644 --- a/tests/Unit/IntropagePanelHelpersTest.php +++ b/tests/Unit/IntropagePanelHelpersTest.php @@ -19,26 +19,29 @@ $GLOBALS['__test_db_calls'] = array(); }); -it('registers a new panel definition and its user-auth column', function () { - $result = intropage_add_panel('my_panel', '/plugins/my_plugin/file.php', 'yes', 3600, 20, 'My Panel'); - - expect($result)->toBe('1'); +it('logs a deprecation warning and does nothing when a panel is added', function () { + $GLOBALS['__test_cacti_log'] = array(); - $call = $GLOBALS['__test_db_calls'][0]; + $result = intropage_add_panel('my_panel', '/plugins/my_plugin/file.php', 'yes', 3600, 20, 'My Panel'); - expect($call['fn'])->toBe('db_execute_prepared'); - expect($call['sql'])->toContain('REPLACE INTO plugin_intropage_panel_definition'); - expect($call['params'])->toBe(array('my_panel', '/plugins/my_plugin/file.php', 'yes', 3600, 20, 'My Panel')); + expect($result)->toBeFalse(); + expect($GLOBALS['__test_db_calls'])->toBe(array()); + expect($GLOBALS['__test_cacti_log'])->toHaveCount(1); + expect($GLOBALS['__test_cacti_log'][0])->toContain('intropage_add_panel() is deprecated'); + expect($GLOBALS['__test_cacti_log'][0])->toContain('my_panel'); + expect($GLOBALS['__test_cacti_log'][0])->toContain('panellib/README.md'); }); -it('removes a panel from every table that references it', function () { - intropage_remove_panel('my_panel'); +it('logs a deprecation warning and does nothing when a panel is removed', function () { + $GLOBALS['__test_cacti_log'] = array(); - expect($GLOBALS['__test_db_calls'])->toHaveCount(3); + $result = intropage_remove_panel('my_panel'); - foreach ($GLOBALS['__test_db_calls'] as $call) { - expect($call['params'])->toBe(array('my_panel')); - } + expect($result)->toBeFalse(); + expect($GLOBALS['__test_db_calls'])->toBe(array()); + expect($GLOBALS['__test_cacti_log'])->toHaveCount(1); + expect($GLOBALS['__test_cacti_log'][0])->toContain('intropage_remove_panel() is deprecated'); + expect($GLOBALS['__test_cacti_log'][0])->toContain('my_panel'); }); it('removes every per-user record on user_remove', function () { diff --git a/tests/Unit/IntropagePanelLeftoversTest.php b/tests/Unit/IntropagePanelLeftoversTest.php new file mode 100644 index 0000000..33612d2 --- /dev/null +++ b/tests/Unit/IntropagePanelLeftoversTest.php @@ -0,0 +1,67 @@ + 'favgraph', 'graph_id' => '5']; + $GLOBALS['__test_query'] = function ($kind, $sql, $params) { + $GLOBALS['__test_queries'][] = $sql; + + // a user without panel rows: MAX(priority) is NULL, so the query only works with COALESCE + if (strpos($sql, 'MAX(priority)') !== false) { + return strpos($sql, 'COALESCE(MAX(priority), 0)') !== false ? '1' : null; + } + + return $kind === 'assoc' ? [] : '0'; + }; + }); + + it('gives the first favourite of a user without panels priority 1', function () { + intropage_actions(); + + $priority_sql = implode("\n", array_filter($GLOBALS['__test_queries'], static fn ($sql) => strpos($sql, 'MAX(priority)') !== false)); + + expect($priority_sql)->toContain('COALESCE(MAX(priority), 0) + 1'); + + $inserts = array_values(array_filter($GLOBALS['__test_db_calls'], static fn ($call) => strpos($call['sql'], 'INSERT INTO plugin_intropage_panel_data') !== false)); + + expect($inserts)->toHaveCount(1); + expect($inserts[0]['params'])->toBe([701, '5', 3, '1']); + }); + + it('links the new favourite to the current dashboard', function () { + intropage_actions(); + + $links = array_values(array_filter($GLOBALS['__test_db_calls'], static fn ($call) => strpos($call['sql'], 'INSERT INTO plugin_intropage_panel_dashboard') !== false)); + + expect($links)->toHaveCount(1); + expect($links[0]['params'][1])->toBe(701); + expect($links[0]['params'][2])->toBe(1); + }); +}); diff --git a/tests/Unit/IntropagePollerOrphansTest.php b/tests/Unit/IntropagePollerOrphansTest.php new file mode 100644 index 0000000..5e8a323 --- /dev/null +++ b/tests/Unit/IntropagePollerOrphansTest.php @@ -0,0 +1,91 @@ + 'alert_host', 'id' => 4, 'user_id' => 12]]; + + $rows = intropage_poller_due_panels(); + + $call = $GLOBALS['__test_db_calls'][0]; + + expect($rows)->toBe([['panel_id' => 'alert_host', 'id' => 4, 'user_id' => 12]]); + expect($call['sql'])->toContain('LEFT JOIN user_auth AS ua'); + expect($call['sql'])->toContain('ON ua.id = ud.user_id'); + expect($call['sql'])->toContain('(ud.user_id IS NULL OR ud.user_id = 0 OR ua.id IS NOT NULL)'); + }); + + it('keeps the original due condition together as one group', function () { + intropage_poller_due_panels(); + + $sql = preg_replace('/\s+/', ' ', $GLOBALS['__test_db_calls'][0]['sql']); + + expect($sql)->toContain('WHERE (UNIX_TIMESTAMP(last_update) < UNIX_TIMESTAMP() - refresh_interval OR (last_update IS NULL AND level = 0)) AND ('); + }); +}); + +describe('intropage_purge_orphan_rows', function () { + it('deletes a bounded number of rows of users that do not exist', function () { + $GLOBALS['__test_fetch_cell'] = '4'; + + intropage_purge_orphan_rows(); + + $deletes = array_values(array_filter($GLOBALS['__test_db_calls'], static fn ($call) => strpos($call['sql'], 'DELETE FROM') !== false)); + + expect($deletes)->toHaveCount(2); + + foreach ($deletes as $delete) { + $sql = preg_replace('/\s+/', ' ', $delete['sql']); + + expect($sql)->toContain('WHERE user_id > 0 AND user_id NOT IN (SELECT id FROM user_auth) LIMIT 500'); + } + + expect($deletes[0]['sql'])->toContain('plugin_intropage_panel_dashboard'); + expect($deletes[1]['sql'])->toContain('plugin_intropage_panel_data'); + }); + + it('uses the limit it is given, and at least one row', function (int $limit, string $expected) { + $GLOBALS['__test_fetch_cell'] = '4'; + + intropage_purge_orphan_rows($limit); + + expect($GLOBALS['__test_db_calls'][1]['sql'])->toContain('LIMIT ' . $expected); + })->with([ + 'ten' => [10, '10'], + 'zero' => [0, '1'], + 'negative' => [-5, '1'], + ]); + + it('deletes nothing when the user table is empty or cannot be read', function (string $count) { + $GLOBALS['__test_fetch_cell'] = $count; + + intropage_purge_orphan_rows(); + + $deletes = array_filter($GLOBALS['__test_db_calls'], static fn ($call) => strpos($call['sql'], 'DELETE FROM') !== false); + + expect($deletes)->toBe([]); + })->with(['zero' => ['0'], 'unreadable' => ['']]); +});