diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 47b49ee..b1a7ef8 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -36,6 +36,13 @@ permissions: env: CACTI: 1.2.x + # Where the end-to-end tests reach the Cacti web server and its database + INTROPAGE_E2E_SCHEME: http + INTROPAGE_E2E_HOST: 127.0.0.1:8080 + INTROPAGE_E2E_DB_HOST: 127.0.0.1 + INTROPAGE_E2E_DB_NAME: cacti + INTROPAGE_E2E_DB_USER: cactiuser + INTROPAGE_E2E_DB_PASS: cactiuser concurrency: group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} @@ -197,7 +204,41 @@ jobs: - name: Set expected Cacti version for unit tests run: echo -n "${{ env.CACTI }}" | sudo tee ${{ github.workspace }}/cacti/plugins/intropage/tests/.cacti-version > /dev/null + # The installer runs as root and leaves cacti.log owned by root; the tests + # restore the log after provoking warnings, so the runner needs write access. + - name: Make the Cacti log writable for the tests + run: | + sudo chown -R www-data:runner ${{ github.workspace }}/cacti/log + sudo find ${{ github.workspace }}/cacti/log -type d -exec chmod 775 {} \; + sudo find ${{ github.workspace }}/cacti/log -type f -exec chmod 664 {} \; + + # The end-to-end tests send real requests to intropage.php, so serve the + # installed Cacti with PHP's built-in server as the web user. + - name: Serve Cacti for the end-to-end tests + run: | + p="${{ github.workspace }}" + while [ "$p" != "/" ]; do + sudo chmod o+x "$p" || true + p=$(dirname "$p") + done + + cd ${{ github.workspace }}/cacti + sudo sed -i "s#^\$url_path = .*#\$url_path = '/';#" include/config.php + sudo -u www-data nohup php -S "$INTROPAGE_E2E_HOST" -t ${{ github.workspace }}/cacti > "${{ runner.temp }}/cacti-web.log" 2>&1 & + + for i in $(seq 1 30); do + if curl -s -o /dev/null --max-time 2 "$INTROPAGE_E2E_SCHEME://$INTROPAGE_E2E_HOST/index.php"; then + exit 0 + fi + sleep 1 + done + + cat "${{ runner.temp }}/cacti-web.log" + exit 1 + - name: Run Pest Unit Tests + env: + INTROPAGE_E2E_CACTI: ${{ github.workspace }}/cacti run: | cd ${{ github.workspace }}/cacti "$CACTI_PEST_BIN" --configuration=plugins/intropage/phpunit.xml \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b3399e..e1f5089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ --- develop --- +* security: Encode pie and treemap chart columns as JSON so device template names can no longer end the string in the inline chart script +* security: Check the panel permission when a panel is added, reloaded or opened in the detail dialog, not only when the dashboard renders +* security: Accept dashboard actions that change data (add, remove, resize, settings, sharing, login option) only as POST so they carry Cacti's CSRF token; the favorite-graph button and the panel tools now post +* issue: Fix the poller stopping with a TypeError, and Cacti disabling the plugin, when a system panel definition has no data row yet; poller rows without an owner now run as user 0 +* issue: Fix the upgrade from 3.0.0 or older failing on PHP 8 while converting per-panel permissions +* issue: Fix deleting a user group leaving its Intropage permission row behind +* issue: Keep a dashboard's shared flag when dashboards are renamed in the settings form +* issue: Apply the Intropage Viewer realm check on online remote pollers as well as on the main poller +* issue: Delete a user's own panel data row when the panel is withdrawn from them +* issue: Restore the hide_disabled user setting when the device lookup fails +* issue: Join threshold event permissions for the viewing user only, so events are no longer repeated once per user holding a permission +* 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/display.php b/display.php index 36fbe0d..123bbc0 100644 --- a/display.php +++ b/display.php @@ -117,40 +117,11 @@ function display_information(): bool { $console_access = api_plugin_user_realm_auth('index.php'); // remove admin prohibited panels - $panels = db_fetch_assoc_prepared('SELECT pd.panel_id AS panel_name, pd.id AS id - FROM plugin_intropage_panel_data AS pd - INNER JOIN plugin_intropage_panel_dashboard AS pda - ON pd.id = pda.panel_id - WHERE pda.user_id = ? - AND pda.dashboard_id = ?', - [$_SESSION['sess_user_id'], $dashboard_id]); - - if (cacti_sizeof($panels)) { - $removed = 0; - - foreach ($panels as $one) { - $allowed = is_panel_allowed($one['panel_name']); + $removed = intropage_remove_prohibited_panels((int) $_SESSION['sess_user_id'], (int) $dashboard_id); - if (!$allowed) { - db_execute_prepared('DELETE FROM plugin_intropage_panel_dashboard - WHERE user_id = ? - AND dashboard_id = ? - AND panel_id = ?', - [$_SESSION['sess_user_id'], $dashboard_id, $one['id']]); - - db_execute_prepared('DELETE FROM plugin_intropage_panel_data - WHERE user_id = ? - AND panel_id = ?', - [$_SESSION['sess_user_id'], $one['id']]); - - $removed++; - } - } - - if ($removed > 0) { - raise_message('intropage_permissions', __('One or more panels was removed due to insufficient permissions. Contact administrator.', 'intropage'), MESSAGE_LEVEL_ERROR); - cacti_log('INTROPAGE WARNING: One or more panels was removed to user ' . $_SESSION['sess_user_id'] . ' due to insufficient permissions'); - } + if ($removed > 0) { + raise_message('intropage_permissions', __('One or more panels was removed due to insufficient permissions. Contact administrator.', 'intropage'), MESSAGE_LEVEL_ERROR); + cacti_log('INTROPAGE WARNING: One or more panels was removed to user ' . $_SESSION['sess_user_id'] . ' due to insufficient permissions'); } if ($display_important_first == 'on') { diff --git a/includes/database.php b/includes/database.php index 319140b..fb8f420 100644 --- a/includes/database.php +++ b/includes/database.php @@ -257,8 +257,6 @@ function intropage_upgrade_database(): void { $panels = initialize_panel_library(); $permissions = db_fetch_assoc('SELECT * FROM plugin_intropage_user_auth'); - $permissions['favourite_graph'] = 'on'; - if (cacti_sizeof($permissions)) { foreach ($permissions as $p) { $user = $p['user_id']; @@ -274,6 +272,8 @@ function intropage_upgrade_database(): void { } } + $p['favourite_graph'] = 'on'; + $perms = json_encode($p); db_execute_prepared('UPDATE plugin_intropage_user_auth diff --git a/includes/functions.php b/includes/functions.php index b5fc45e..758adb6 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -52,10 +52,13 @@ function intropage_get_allowed_devices($user_id) { set_user_setting('hide_disabled', '', $user_id); } - $allowed = get_allowed_devices('', 'null', '-1', $x, (int) $user_id); - - if ($us == 'on') { - set_user_setting('hide_disabled', 'on', $user_id); + try { + $allowed = get_allowed_devices('', 'null', '-1', $x, (int) $user_id); + } finally { + // Put the stored preference back even when the lookup throws + if ($us == 'on') { + set_user_setting('hide_disabled', 'on', $user_id); + } } if (cacti_count($allowed)) { @@ -104,14 +107,27 @@ function intropage_device_scope($user_id): array { function process_page_request_variables(): void { set_default_action(); + // Actions that change data are only honoured as POST requests, which + // Cacti protects with its CSRF token. Rendering the panel select is the + // one read-only intropage_action. + $posted = intropage_is_post_request(); + if (isset_request_var('intropage_addpanel')) { - intropage_action_add_panel(); + if ($posted) { + intropage_action_add_panel(); + } } elseif (isset_request_var('action') && isset_request_var('save_settings')) { - intropage_action_settings(); + if ($posted) { + intropage_action_settings(); + } } elseif (isset_request_var('intropage_action')) { - intropage_actions(); + if ($posted || get_nfilter_request_var('intropage_action') == 'addpanelselect') { + intropage_actions(); + } } elseif (isset_request_var('intropage_action_timespan')) { - intropage_actions_timespan(); + if ($posted) { + intropage_actions_timespan(); + } } elseif (get_request_var('action') != '') { switch(get_request_var('action')) { case 'configure': @@ -136,6 +152,15 @@ function process_page_request_variables(): void { } } +/** + * Tells whether the current request was made with the POST method. + * + * @return bool True for a POST request. + */ +function intropage_is_post_request(): bool { + return ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST'; +} + /** * Handles adding a panel to the current dashboard: creates a new * plugin_intropage_panel_data row for the panel if one doesn't already @@ -150,6 +175,16 @@ function intropage_action_add_panel(): void { if (is_numeric(get_nfilter_request_var('intropage_addpanel'))) { $addpanel = get_filter_request_var('intropage_addpanel'); + + $panel_name = db_fetch_cell_prepared('SELECT panel_id + FROM plugin_intropage_panel_data + WHERE id = ? + AND user_id IN (0, ?)', + [$addpanel, $_SESSION['sess_user_id']]); + + if (!is_panel_allowed((string) $panel_name)) { + return; + } } else { $panel_id = get_nfilter_request_var('intropage_addpanel'); @@ -158,7 +193,7 @@ function intropage_action_add_panel(): void { WHERE panel_id = ?', [$panel_id]); - if (!is_array($panel)) { + if (!is_array($panel) || !is_panel_allowed($panel_id)) { return; } @@ -225,14 +260,16 @@ function intropage_action_settings(): void { if (strpos($var, 'name_') !== false) { $dashboard_id = intropage_parse_dashboard_id(str_replace('name_', '', $var)); - if ($dashboard_id === null) { + if ($dashboard_id === null || !is_string($value)) { continue; } - db_execute_prepared('REPLACE INTO plugin_intropage_dashboard - (user_id, dashboard_id, name) - VALUES (?, ?, ?)', - [$_SESSION['sess_user_id'], $dashboard_id, $value]); + // UPDATE keeps the shared flag, which REPLACE would reset + db_execute_prepared('UPDATE plugin_intropage_dashboard + SET name = ? + WHERE user_id = ? + AND dashboard_id = ?', + [$value, $_SESSION['sess_user_id'], $dashboard_id]); } } @@ -464,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']]); @@ -920,6 +957,66 @@ function get_allowed_panels(int $user_id = 0) { return (cacti_sizeof($permissions) ? $permissions : false); } +/** + * Removes the panels of a dashboard that the user is no longer permitted to + * see: the dashboard link and the user's own panel data row. + * + * @param int $user_id The user whose dashboard is cleaned up. + * @param int $dashboard_id The dashboard to clean up. + * + * @return int The number of panels removed. + */ +function intropage_remove_prohibited_panels(int $user_id, int $dashboard_id): int { + $panels = db_fetch_assoc_prepared('SELECT pd.panel_id AS panel_name, pd.id AS id + FROM plugin_intropage_panel_data AS pd + INNER JOIN plugin_intropage_panel_dashboard AS pda + ON pd.id = pda.panel_id + WHERE pda.user_id = ? + AND pda.dashboard_id = ?', + [$user_id, $dashboard_id]); + + $removed = 0; + + foreach ($panels as $one) { + if (!is_panel_allowed($one['panel_name'])) { + db_execute_prepared('DELETE FROM plugin_intropage_panel_dashboard + WHERE user_id = ? + AND dashboard_id = ? + AND panel_id = ?', + [$user_id, $dashboard_id, $one['id']]); + + db_execute_prepared('DELETE FROM plugin_intropage_panel_data + WHERE user_id = ? + AND id = ?', + [$user_id, $one['id']]); + + $removed++; + } + } + + return $removed; +} + +/** + * Determines whether the current user may view an existing panel row. The + * admin alert and maintenance panels are rendered for every user and are not + * listed on the permission forms, so they are always visible; every other + * panel needs the per-user or per-group permission. + * + * @param mixed $panel_id The panel_id (name) stored on the panel data row. + * + * @return bool True if the panel may be shown to the current user. + */ +function intropage_panel_visible($panel_id): bool { + $panel_id = (string) $panel_id; + + if ($panel_id === 'admin_alert' || $panel_id === 'maint') { + return true; + } + + return is_panel_allowed($panel_id); +} + /** * Builds the force-reload control shown in a force-enabled panel's * header. Clicking it invokes reload_panel() (see includes/intropage.js), @@ -1025,6 +1122,12 @@ function intropage_reload_panel(): void { AND user_id IN (0, ?)', [$panel_id, $_SESSION['sess_user_id']]); + if (is_array($panel) && cacti_sizeof($panel) && !intropage_panel_visible($panel['panel_id'])) { + print __('Panel not found', 'intropage'); + + return; + } + // Close the session to allow other tabs to operate session_write_close(); @@ -1145,6 +1248,19 @@ function intropage_detail_panel(): void { $forced_update = false; } + // Look the row up before get_panel(), which would create a row for an id that does not exist + $panel_name = db_fetch_cell_prepared('SELECT panel_id + FROM plugin_intropage_panel_data + WHERE id = ? + AND user_id IN (0, ?)', + [$panel_id, $_SESSION['sess_user_id']]); + + if (!intropage_panel_visible($panel_name)) { + print __('Panel Not Found', 'intropage'); + + return; + } + $panel = get_panel($panel_id, $_SESSION['sess_user_id']); // Close the session to allow other tabs to operate @@ -1406,6 +1522,75 @@ function get_user_list(): array { return $users; } +/** + * Sets the session user a poller row runs as and returns it as an integer. + * Panel definitions that have no data row yet come back from the poller's + * LEFT JOIN with a NULL user_id; those are system panels and run as user 0, + * so they neither fail get_panel()'s int parameter nor inherit the user of + * the row processed before them. + * + * @param mixed $user_id The user_id column of the poller row, possibly null. + * + * @return int The user id now stored in the session. + */ +function intropage_poller_impersonate($user_id): int { + $user_id = (int) $user_id; + + $_SESSION['sess_user_id'] = $user_id; + + 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 @@ -1717,6 +1902,23 @@ function intropage_favourite_graph(int $fav_graph_id, int $fav_graph_timespan) { return null; } +/** + * Encodes one pie or treemap data column as a JavaScript array literal. + * Labels come from the database (for example device template names), so + * they are encoded as JSON with the characters that can end a string or a + * script element escaped, instead of being pasted into the script. + * + * @param mixed $label The column label. + * @param mixed $value The column value; non-numeric values become 0. + * + * @return string The column literal followed by a trailing comma. + */ +function intropage_chart_column($label, $value): string { + $flags = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_INVALID_UTF8_SUBSTITUTE; + + return json_encode([(string) $label, is_numeric($value) ? $value + 0 : 0], $flags) . ','; +} + /** * Renders one of this plugin's panel chart types (line, pie, or other * supported chart shape described by $dispdata) as embedded HTML/JS @@ -1952,7 +2154,7 @@ function intropage_prepare_graph(array $dispdata, int $user_id): string { $content .= ' columns: ['; foreach ($dispdata['pie']['data'] as $key => $value) { - $content .= "['" . $dispdata['pie']['label'][$key] . "', " . $value . '],'; + $content .= intropage_chart_column($dispdata['pie']['label'][$key], $value); } $content .= ' ],'; @@ -1989,7 +2191,7 @@ function intropage_prepare_graph(array $dispdata, int $user_id): string { $content .= ' columns: ['; foreach ($dispdata['treemap']['data'] as $key => $value) { - $content .= "['" . $dispdata['treemap']['label'][$key] . "', " . $value . '],'; + $content .= intropage_chart_column($dispdata['treemap']['label'][$key], $value); } $content .= ' ],'; @@ -2236,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 array $records Records as returned by dns_get_record(); AAAA + * records carry 'ipv6', A records carry 'ip'. * - * @param string $host The NTP server hostname/IP to query. + * @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. * - * @return int|string The remote Unix timestamp, or an 'error: ...' - * string describing a socket failure. + * @param string $host The host name. * - * @global array $config Cacti global configuration array; used to - * select the correct socket receive mode on - * Windows vs. other platforms. + * @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)); + + return array_merge(array_slice($ipv6, 0, 1), array_slice($ipv4, 0, 1)); +} - // send an NTP request to the server - $request = "\x1b" . str_repeat("\x00", 47); +/** + * 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; - if (socket_sendto($sock, $request, strlen($request), 0, $host, 123) === false) { - $error = socket_strerror(socket_last_error($sock)); - socket_close($sock); + // 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); +} - return 'error: unpack failed'; +/** + * 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); + + 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 @@ -2338,6 +2608,8 @@ function ntp_time(string $host) { function intropage_graph_button($data): void { global $config, $callbackPage, $redirectPage; + static $script_printed = false; + $login_opts = get_login_opts(); if ($login_opts == 4) { @@ -2368,10 +2640,40 @@ function intropage_graph_button($data): void { } } - print '' . $fav . '
'; + print '' . $fav . '
'; + + if (!$script_printed) { + $script_printed = true; + + print intropage_favgraph_script(); + } } } +/** + * Builds the script that turns a favorite-graph button click into a POST to + * the dashboard page. The button is rendered on Cacti's graph pages, outside + * the dashboard's own JavaScript, and a state change has to carry the CSRF + * token. + * + * @return string The script element, bound once per page. + */ +function intropage_favgraph_script(): string { + $nonce = function_exists('plugin_intropage_csp_nonce') ? plugin_intropage_csp_nonce() : ''; + + return ''; +} + /** * Resolves (and caches in the session) the current user's effective * login-options value, handling explicit 'loginopt_console'/ @@ -2386,14 +2688,17 @@ function intropage_graph_button($data): void { * @return int The resolved login_opts value. */ function get_login_opts(bool $refresh = false): int { - if (isset_request_var('intropage_action') && - get_nfilter_request_var('intropage_action') == 'loginopt_console') { + $requested = ''; + + if (isset_request_var('intropage_action') && intropage_is_post_request()) { + $requested = get_nfilter_request_var('intropage_action'); + } + + if ($requested == 'loginopt_console') { $_SESSION['intropage_login_opts'] = 1; - } elseif (isset_request_var('intropage_action') && - get_nfilter_request_var('intropage_action') == 'loginopt_tab') { + } elseif ($requested == 'loginopt_tab') { $_SESSION['intropage_login_opts'] = 4; - } elseif (isset_request_var('intropage_action') && - get_nfilter_request_var('intropage_action') == 'loginopt_graph') { + } elseif ($requested == 'loginopt_graph') { $_SESSION['intropage_login_opts'] = 2; } elseif (empty($_SESSION['intropage_login_opts']) || $refresh) { $login_opts = db_fetch_cell_prepared('SELECT login_opts diff --git a/includes/intropage.js b/includes/intropage.js index 4c8dca5..d8e83f0 100644 --- a/includes/intropage.js +++ b/includes/intropage.js @@ -125,13 +125,28 @@ function addPanel() { } } +// Navigate to a page with a POST so the request carries Cacti's CSRF token; +// a plain link or document.location is a GET and cannot change settings. +function intropage_post_navigate(url, fields) { + var form = $('
').attr('action', url); + + fields.__csrf_magic = csrfMagicToken; + + $.each(fields, function(name, value) { + $('').attr('name', name).val(value).appendTo(form); + }); + + form.appendTo('body'); + form.get(0).submit(); +} + function actionPanel() { var option = $('#intropage_action').val(); if (option == 'loginopt_tab') { - document.location = urlPath + 'plugins/intropage/intropage.php?dashboard_id='+dashboard_id+'&intropage_action='+option; + intropage_post_navigate(urlPath + 'plugins/intropage/intropage.php', { dashboard_id: dashboard_id, intropage_action: option }); } else if (option == 'loginopt_console') { - document.location = urlPath + 'index.php?dashboard_id='+dashboard_id+'&intropage_action='+option; + intropage_post_navigate(urlPath + 'index.php', { dashboard_id: dashboard_id, intropage_action: option }); } else { var post = { header: 'false', @@ -156,9 +171,9 @@ function timeSpan() { var option = $('#intropage_action_timespan').val(); if (option == 'loginopt_tab') { - document.location = urlPath + 'plugins/intropage/intropage.php?dashboard_id='+dashboard_id+'&intropage_action_timespan='+option; + intropage_post_navigate(urlPath + 'plugins/intropage/intropage.php', { dashboard_id: dashboard_id, intropage_action_timespan: option }); } else if (option == 'loginopt_console') { - document.location = urlPath + 'index.php?dashboard_id='+dashboard_id+'&intropage_action_timespan='+option; + intropage_post_navigate(urlPath + 'index.php', { dashboard_id: dashboard_id, intropage_action_timespan: option }); } else { var post = { header: 'false', @@ -301,8 +316,15 @@ function initPage() { onRemoveCard: function(id) { var panel_id = String(id).split('_').pop(); var page = redirectPage + '?intropage_action=droppanel&panel_id=' + panel_id + '&dashboard_id=' + dashboard_id; + var drop = { + header: 'false', + intropage_action: 'droppanel', + panel_id: panel_id, + dashboard_id: dashboard_id, + __csrf_magic: csrfMagicToken + }; - $.get(page, function() { + $.post(redirectPage, drop, function() { var url = page.replace('droppanel', 'addpanelselect&header=false'); $.get(url) @@ -334,9 +356,15 @@ function initPage() { } var action = (dir === 'grow') ? 'heightmore' : 'heightless'; - var url = urlPath + 'plugins/intropage/intropage.php?&intropage_action=' + action + '&panel_id=' + panel_id; + var url = urlPath + 'plugins/intropage/intropage.php'; + var resize = { + header: 'false', + intropage_action: action, + panel_id: panel_id, + __csrf_magic: csrfMagicToken + }; - $.get(url) + $.post(url, resize) .done(function() { reload_panel(panel_id, true, false); }) diff --git a/includes/settings.php b/includes/settings.php index f0f8a41..f27d672 100644 --- a/includes/settings.php +++ b/includes/settings.php @@ -131,7 +131,7 @@ function intropage_console_after(): void { $login_opts = get_login_opts(true); - if (api_user_realm_auth('intropage.php') && $config['poller_id'] == 1 || ($config['poller_id'] > 1 && $config['connection'] == 'online')) { + if (api_user_realm_auth('intropage.php') && ($config['poller_id'] == 1 || ($config['poller_id'] > 1 && $config['connection'] == 'online'))) { if ($login_opts != 4) { $panels = initialize_panel_library(); diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index cbd0671..ff2e65b 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti \n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-10 09:28-0400\n" +"POT-Creation-Date: 2026-10-23 21:21-0600\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers >\n" "Language-Team: Cacti Developers \n" @@ -298,6 +298,10 @@ msgstr "" msgid "Disable panel" msgstr "" +#: includes/functions.php +msgid "Panel not found" +msgstr "" + #: includes/functions.php msgid "The Panel includes a render function but it does not exist." msgstr "" @@ -315,17 +319,13 @@ msgid "No Data Found. Either wait for next check,
or use the Force Reload msgstr "" #: includes/functions.php -msgid "Panel not found" +msgid "Panel Not Found" msgstr "" #: includes/functions.php msgid "Details Function does not exist." msgstr "" -#: includes/functions.php -msgid "Panel Not Found" -msgstr "" - #: includes/functions.php #, php-format msgid " [Upd. in %s/%s]" diff --git a/panellib/thold.php b/panellib/thold.php index fa85410..e22af6a 100644 --- a/panellib/thold.php +++ b/panellib/thold.php @@ -166,7 +166,7 @@ function thold_event($panel, $user_id): void { } if ($allowed_devices !== false || $simple_perms) { - $data = db_fetch_assoc("SELECT tl.description AS description,tl.time AS time, + $data = db_fetch_assoc_prepared("SELECT tl.description AS description,tl.time AS time, tl.status AS status, uap0.user_id AS user0, uap1.user_id AS user1, uap2.user_id AS user2 FROM plugin_thold_log AS tl INNER JOIN thold_data AS td @@ -180,15 +180,16 @@ function thold_event($panel, $user_id): void { LEFT JOIN host AS h ON h.id=gl.host_id LEFT JOIN user_auth_perms AS uap0 - ON (gl.id=uap0.item_id AND uap0.type=1) + ON (gl.id=uap0.item_id AND uap0.type=1 AND uap0.user_id = ?) LEFT JOIN user_auth_perms AS uap1 - ON (gl.host_id=uap1.item_id AND uap1.type=3) + ON (gl.host_id=uap1.item_id AND uap1.type=3 AND uap1.user_id = ?) LEFT JOIN user_auth_perms AS uap2 - ON (gl.graph_template_id=uap2.item_id AND uap2.type=4) + ON (gl.graph_template_id=uap2.item_id AND uap2.type=4 AND uap2.user_id = ?) $q_host_cond HAVING (user0 IS NULL OR (user1 IS NULL OR user2 IS NULL)) ORDER BY `time` DESC - LIMIT " . $lines); + LIMIT " . $lines, + [$user_id, $user_id, $user_id]); } else { $data = []; } @@ -551,7 +552,7 @@ function thold_event_detail(): array { $q_host_cond = ''; } - $data = db_fetch_assoc("SELECT tl.description as description,tl.time as time, + $data = db_fetch_assoc_prepared("SELECT tl.description as description,tl.time as time, tl.status as status, uap0.user_id AS user0, uap1.user_id AS user1, uap2.user_id AS user2 FROM plugin_thold_log AS tl INNER JOIN thold_data AS td @@ -565,15 +566,16 @@ function thold_event_detail(): array { LEFT JOIN host AS h ON h.id=gl.host_id LEFT JOIN user_auth_perms AS uap0 - ON (gl.id=uap0.item_id AND uap0.type=1) + ON (gl.id=uap0.item_id AND uap0.type=1 AND uap0.user_id = ?) LEFT JOIN user_auth_perms AS uap1 - ON (gl.host_id=uap1.item_id AND uap1.type=3) + ON (gl.host_id=uap1.item_id AND uap1.type=3 AND uap1.user_id = ?) LEFT JOIN user_auth_perms AS uap2 - ON (gl.graph_template_id=uap2.item_id AND uap2.type=4) + ON (gl.graph_template_id=uap2.item_id AND uap2.type=4 AND uap2.user_id = ?) $q_host_cond HAVING (user0 IS NULL OR (user1 IS NULL OR user2 IS NULL)) ORDER BY `time` DESC - LIMIT 30"); + LIMIT 30", + [$_SESSION['sess_user_id'], $_SESSION['sess_user_id'], $_SESSION['sess_user_id']]); if (cacti_sizeof($data)) { $panel['detail'] .= ''; diff --git a/poller_intropage.php b/poller_intropage.php index 3552726..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 @@ -246,10 +242,8 @@ function intropage_gather_stats(): array { foreach ($upanels as $upanel) { $start = microtime(true); - // Fake a session variable - if ($upanel['user_id'] > 0) { - $_SESSION['sess_user_id'] = $upanel['user_id']; - } + // Fake a session variable; system panels (no owner) run as user 0 + $upanel['user_id'] = intropage_poller_impersonate($upanel['user_id']); // Required plugin is not installed, but entry not purged if (!isset($panels[$upanel['panel_id']])) { diff --git a/setup.php b/setup.php index 5545fb6..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; } /** @@ -632,5 +613,5 @@ function intropage_user_remove($user_id): void { * @return void */ function intropage_user_group_remove($group_id): void { - db_execute_prepared('DELETE FROM plugin_intropage_user_group_auth WHERE id = ?', [$group_id]); + db_execute_prepared('DELETE FROM plugin_intropage_user_group_auth WHERE user_group_id = ?', [$group_id]); } diff --git a/tests/Integration/IntropageHttpTest.php b/tests/Integration/IntropageHttpTest.php new file mode 100644 index 0000000..e3451bf --- /dev/null +++ b/tests/Integration/IntropageHttpTest.php @@ -0,0 +1,1540 @@ + (getenv('INTROPAGE_E2E_SCHEME') ?: 'http') . '://' . $host, + 'dsn' => 'mysql:host=' . (getenv('INTROPAGE_E2E_DB_HOST') ?: '127.0.0.1') . ';dbname=' . $db, + 'user' => (string) getenv('INTROPAGE_E2E_DB_USER'), + 'pass' => (string) getenv('INTROPAGE_E2E_DB_PASS'), + 'cacti' => getenv('INTROPAGE_E2E_CACTI') ?: dirname(__DIR__, 3), + 'cov' => getenv('INTROPAGE_COVERAGE_DIR') ?: '', + ]; +} + +/** + * Minimal cookie-keeping HTTP client; redirects are reported, not followed. + */ +final class IntropageE2eClient { + /** @var array */ + private array $cookies = []; + + private string $base; + + public function __construct(string $base) { + $this->base = $base; + } + + /** + * @param array $fields + * + * @return array{status: int, location: string, body: string} + */ + public function request(string $method, string $path, array $fields = []): array { + $header = "User-Agent: intropage-e2e\r\n"; + + foreach ($this->cookies as $name => $value) { + $header .= "Cookie: $name=$value\r\n"; + } + + $http = [ + 'method' => $method, + 'ignore_errors' => true, + 'follow_location' => 0, + 'timeout' => 120, + ]; + + if ($method === 'POST') { + $header .= "Content-Type: application/x-www-form-urlencoded\r\n"; + $http['content'] = http_build_query($fields); + } + + $http['header'] = $header; + + $body = file_get_contents($this->base . $path, false, stream_context_create(['http' => $http])); + + $status = 0; + $location = ''; + + foreach ($http_response_header ?? [] as $line) { + if (preg_match('#^HTTP/\S+ (\d+)#', $line, $m)) { + $status = (int) $m[1]; + } elseif (stripos($line, 'Location:') === 0) { + $location = trim(substr($line, 9)); + } elseif (stripos($line, 'Set-Cookie:') === 0 && preg_match('/^Set-Cookie:\s*([^=;\s]+)=([^;]*)/i', $line, $m)) { + $this->cookies[$m[1]] = $m[2]; + } + } + + return ['status' => $status, 'location' => $location, 'body' => (string) $body]; + } + + public function login(string $user, string $pass): bool { + $page = $this->request('GET', '/index.php'); + + if (!preg_match('/name=.__csrf_magic. value="([^"]+)"/', $page['body'], $m)) { + return false; + } + + $result = $this->request('POST', '/index.php', [ + '__csrf_magic' => $m[1], + 'action' => 'login', + 'login_username' => $user, + 'login_password' => $pass, + ]); + + return $result['status'] === 302; + } + + /** + * Fetches the CSRF token embedded in a rendered dashboard page. + */ + public function token(string $path = INTROPAGE_E2E_PATH): string { + $page = $this->request('GET', $path); + + preg_match('/csrfMagicToken\s*=\s*[\'"]([^\'"]+)[\'"]/', $page['body'], $m); + + return $m[1] ?? ''; + } + + /** + * Sends a POST that carries a valid CSRF token. + * + * @param array $fields + * + * @return array{status: int, location: string, body: string} + */ + public function post(string $path, array $fields): array { + $fields['__csrf_magic'] = $this->token(); + + return $this->request('POST', $path, $fields); + } + + public function get(string $query = '', string $path = INTROPAGE_E2E_PATH): array { + return $this->request('GET', $path . ($query === '' ? '' : '?' . $query)); + } +} + +/* ------------------------------------------------------------------ */ +/* database helpers */ +/* ------------------------------------------------------------------ */ + +function intropage_e2e_db(): PDO { + static $pdo = null; + + if ($pdo === null) { + $config = intropage_e2e_config(); + + $pdo = new PDO($config['dsn'], $config['user'], $config['pass'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + } + + return $pdo; +} + +function intropage_e2e_cell(string $sql, array $params = []) { + $stmt = intropage_e2e_db()->prepare($sql); + $stmt->execute($params); + + return $stmt->fetchColumn(); +} + +function intropage_e2e_rows(string $sql, array $params = []): array { + $stmt = intropage_e2e_db()->prepare($sql); + $stmt->execute($params); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); +} + +function intropage_e2e_exec(string $sql, array $params = []): void { + intropage_e2e_db()->prepare($sql)->execute($params); +} + +function intropage_e2e_realm(string $file): int { + return 100 + (int) intropage_e2e_cell('SELECT id FROM plugin_realms WHERE file = ?', [$file]); +} + +/** + * Creates (or recreates) a local user with exactly the given realms. + * + * @param int[] $realms + */ +function intropage_e2e_make_user(string $name, array $realms, int $login_opts = 4): int { + intropage_e2e_drop_user($name); + + intropage_e2e_exec( + 'INSERT INTO user_auth (username, password, realm, full_name, must_change_password, enabled, login_opts) VALUES (?, ?, 0, ?, ?, ?, ?)', + [$name, password_hash(INTROPAGE_E2E_PASS, PASSWORD_DEFAULT), $name, '', 'on', $login_opts] + ); + + $id = (int) intropage_e2e_cell('SELECT id FROM user_auth WHERE username = ?', [$name]); + + foreach ($realms as $realm) { + intropage_e2e_exec('INSERT INTO user_auth_realm (user_id, realm_id) VALUES (?, ?)', [$id, $realm]); + } + + return $id; +} + +function intropage_e2e_user_id(string $name): int { + return (int) intropage_e2e_cell('SELECT id FROM user_auth WHERE username = ?', [$name]); +} + +function intropage_e2e_drop_user(string $name): void { + $id = intropage_e2e_user_id($name); + + if ($id === 0) { + return; + } + + foreach ([ + 'DELETE FROM plugin_intropage_panel_dashboard WHERE user_id = ?', + 'DELETE FROM plugin_intropage_panel_data WHERE user_id = ?', + 'DELETE FROM plugin_intropage_dashboard WHERE user_id = ?', + 'DELETE FROM plugin_intropage_user_auth WHERE user_id = ?', + 'DELETE FROM user_auth_realm WHERE user_id = ?', + 'DELETE FROM user_auth_perms WHERE user_id = ?', + 'DELETE FROM settings_user WHERE user_id = ?', + ] as $sql) { + intropage_e2e_exec($sql, [$id]); + } + + intropage_e2e_exec('DELETE FROM user_auth WHERE id = ?', [$id]); +} + +/** + * Stores the panel permissions of a user as the plugin does. + * + * @param string[] $panels + */ +function intropage_e2e_set_permissions(string $user, array $panels): void { + $permissions = []; + + foreach ($panels as $panel) { + $permissions[$panel] = 'on'; + } + + intropage_e2e_exec('REPLACE INTO plugin_intropage_user_auth (user_id, permissions) VALUES (?, ?)', [intropage_e2e_user_id($user), json_encode($permissions)]); +} + +/** + * Removes the dashboards and panel rows of a user and gives it one dashboard. + */ +function intropage_e2e_reset(string $user): void { + $id = intropage_e2e_user_id($user); + + foreach ([ + 'DELETE FROM plugin_intropage_panel_dashboard WHERE user_id = ?', + 'DELETE FROM plugin_intropage_panel_data WHERE user_id = ?', + 'DELETE FROM plugin_intropage_dashboard WHERE user_id = ?', + 'DELETE FROM settings_user WHERE user_id = ?', + ] as $sql) { + intropage_e2e_exec($sql, [$id]); + } + + intropage_e2e_exec('INSERT INTO plugin_intropage_dashboard (user_id, dashboard_id, name, shared) VALUES (?, 1, ?, 0)', [$id, 'Default']); +} + +function intropage_e2e_panel_row(string $panel_id, int $user_id): int { + return (int) intropage_e2e_cell('SELECT id FROM plugin_intropage_panel_data WHERE panel_id = ? AND user_id = ?', [$panel_id, $user_id]); +} + +/** + * Inserts a panel data row for a user and links it to a dashboard. + * + * @return int The id of the new panel data row. + */ +function intropage_e2e_seed_panel(string $user, string $panel_id, int $dashboard = 1, string $height = 'normal'): int { + $id = intropage_e2e_user_id($user); + + 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(), NOW())', + [$panel_id, $id, 'green', $height] + ); + + $row = (int) intropage_e2e_cell('SELECT MAX(id) FROM plugin_intropage_panel_data WHERE panel_id = ? AND user_id = ?', [$panel_id, $id]); + + intropage_e2e_exec('INSERT INTO plugin_intropage_panel_dashboard (panel_id, user_id, dashboard_id) VALUES (?, ?, ?)', [$row, $id, $dashboard]); + + return $row; +} + +/** + * Creates a device with one graph on it. + * + * @return array{int, int} The device id and the local graph id. + */ +function intropage_e2e_seed_graph(string $title): array { + intropage_e2e_exec('INSERT INTO host (description, hostname) VALUES (?, ?)', ['e2e-graph-host', 'e2e-graph-host.invalid']); + + $host = (int) intropage_e2e_cell('SELECT MAX(id) FROM host WHERE description = ?', ['e2e-graph-host']); + + intropage_e2e_exec('INSERT INTO graph_local (host_id, graph_template_id, snmp_query_id, snmp_index) VALUES (?, 0, 0, ?)', [$host, '']); + + $graph = (int) intropage_e2e_cell('SELECT MAX(id) FROM graph_local WHERE host_id = ?', [$host]); + + intropage_e2e_exec('INSERT INTO graph_templates_graph (local_graph_id, title, title_cache, height, width, image_format_id) VALUES (?, ?, ?, 100, 200, 1)', [$graph, $title, $title]); + + return [$host, $graph]; +} + +/** + * Removes the devices and graphs made by intropage_e2e_seed_graph(). + */ +function intropage_e2e_drop_graphs(): void { + foreach (intropage_e2e_rows('SELECT id FROM host WHERE description = ?', ['e2e-graph-host']) as $host) { + foreach (intropage_e2e_rows('SELECT id FROM graph_local WHERE host_id = ?', [$host['id']]) as $graph) { + intropage_e2e_exec('DELETE FROM graph_templates_graph WHERE local_graph_id = ?', [$graph['id']]); + intropage_e2e_exec('DELETE FROM graph_local WHERE id = ?', [$graph['id']]); + } + + intropage_e2e_exec('DELETE FROM host WHERE id = ?', [$host['id']]); + } +} + +/** + * Returns the logged-in client for a fixture user, logging in on first use. + */ +function intropage_e2e_client(string $user): IntropageE2eClient { + static $clients = []; + + if (!isset($clients[$user])) { + $client = new IntropageE2eClient(intropage_e2e_config()['base']); + + expect($client->login($user, INTROPAGE_E2E_PASS))->toBeTrue("login as $user failed"); + + $clients[$user] = $client; + } + + return $clients[$user]; +} + +/* ------------------------------------------------------------------ */ +/* cacti.log and coverage helpers */ +/* ------------------------------------------------------------------ */ + +function intropage_e2e_log_file(): string { + return intropage_e2e_config()['cacti'] . '/log/cacti.log'; +} + +/** + * Returns the size of cacti.log, or null when it is missing or not writable + * by this process (the CI job makes it writable before the tests run). + */ +function intropage_e2e_log_size(): ?int { + $log = intropage_e2e_log_file(); + + return (is_file($log) && is_writable($log)) ? (int) filesize($log) : null; +} + +/** + * Lines added to cacti.log since a mark that match the workflow's error scan. + * + * @return string[] + */ +function intropage_e2e_log_findings(int $size): array { + $text = (string) file_get_contents(intropage_e2e_log_file(), false, null, $size); + + return array_values(array_filter(explode("\n", $text), static function (string $line): bool { + return preg_match('/FATAL:|ERROR:|WARNING:|ERROR |WARNING /', $line) === 1; + })); +} + +function intropage_e2e_log_restore(int $size): void { + $handle = fopen(intropage_e2e_log_file(), 'r+'); + + if ($handle !== false) { + ftruncate($handle, $size); + fclose($handle); + } +} + +/** + * Registers log lines the running test provokes on purpose. + */ +function intropage_e2e_expect_log(string ...$patterns): void { + $GLOBALS['__intropage_e2e_expected_log'] = array_merge($GLOBALS['__intropage_e2e_expected_log'] ?? [], $patterns); +} + +/** + * Tells the optional coverage prepend file which test is running. + */ +function intropage_e2e_coverage_token(string $name): void { + $dir = intropage_e2e_config()['cov']; + + if ($dir !== '' && is_dir($dir)) { + $slug = trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower(str_replace('__pest_evaluable_', '', $name))), '-'); + + file_put_contents($dir . '/current', substr($slug, 0, 80)); + } +} + +/** + * Runs the real poller script. + * + * @return array{status: int, output: string} + */ +function intropage_e2e_poller(): array { + $config = intropage_e2e_config(); + $script = $config['cacti'] . '/plugins/intropage/poller_intropage.php'; + $prefix = ''; + + if ($config['cov'] !== '') { + $prepend = $config['cacti'] . '/plugins/intropage/tests/Integration/coverage-prepend.php'; + $prefix = ' -d memory_limit=1G -d xdebug.mode=coverage -d auto_prepend_file=' . escapeshellarg($prepend); + } + + $output = []; + $status = 0; + + exec(escapeshellarg(PHP_BINARY) . $prefix . ' -q ' . escapeshellarg($script) . ' 2>&1', $output, $status); + + return ['status' => $status, 'output' => implode("\n", $output)]; +} + +/* ------------------------------------------------------------------ */ +/* fixture */ +/* ------------------------------------------------------------------ */ + +beforeAll(function () { + if (intropage_e2e_config() === null) { + return; + } + + $viewer = intropage_e2e_realm('intropage.php'); + + intropage_e2e_make_user('e2e_viewer', [$viewer]); + intropage_e2e_make_user('e2e_other', [$viewer]); + intropage_e2e_make_user('e2e_console', [$viewer, INTROPAGE_E2E_REALM_CONSOLE], 1); + intropage_e2e_make_user('e2e_noplugin', [INTROPAGE_E2E_REALM_CONSOLE], 1); + intropage_e2e_make_user('e2e_admin', [$viewer, INTROPAGE_E2E_REALM_USER_ADMIN]); +}); + +afterAll(function () { + if (intropage_e2e_config() === null) { + return; + } + + foreach (['e2e_viewer', 'e2e_other', 'e2e_console', 'e2e_noplugin', 'e2e_admin'] as $user) { + intropage_e2e_drop_user($user); + } + + intropage_e2e_drop_graphs(); + intropage_e2e_exec("DELETE FROM host WHERE description LIKE 'e2e-%'"); + intropage_e2e_exec("DELETE FROM host_template WHERE name LIKE 'e2e-%'"); + intropage_e2e_exec('DELETE FROM host_template WHERE name = ?', [str_repeat("\u{20ac}", 8)]); + intropage_e2e_exec("DELETE FROM user_auth_group WHERE name LIKE 'e2e-%'"); + intropage_e2e_exec('DELETE FROM plugin_intropage_panel_data WHERE user_id > 0 AND user_id NOT IN (SELECT id FROM user_auth)'); +}); + +beforeEach(function () { + if (intropage_e2e_config() === null) { + $this->markTestSkipped('Set INTROPAGE_E2E_HOST and INTROPAGE_E2E_DB_NAME to run the end-to-end tests.'); + } + + $GLOBALS['__intropage_e2e_expected_log'] = []; + $GLOBALS['__intropage_e2e_log_mark'] = intropage_e2e_log_size(); + + intropage_e2e_coverage_token($this->name()); + + foreach (['e2e_viewer', 'e2e_other'] as $user) { + intropage_e2e_reset($user); + intropage_e2e_set_permissions($user, ['alert_host', 'graph_host_template', 'favourite_graph']); + } + + intropage_e2e_exec('UPDATE user_auth SET login_opts = 4 WHERE username IN (?, ?)', ['e2e_viewer', 'e2e_other']); + + // A client keeps the dashboard it last used in its session; start each test on dashboard 1 + foreach (['e2e_viewer', 'e2e_other'] as $user) { + intropage_e2e_client($user)->get('dashboard_id=1&header=false'); + } +}); + +afterEach(function () { + if (intropage_e2e_config() === null) { + return; + } + + $mark = $GLOBALS['__intropage_e2e_log_mark']; + + if ($mark === null) { + return; + } + + $allowed = array_merge(['DBCALL ERROR: Column: height contains an invalid value'], $GLOBALS['__intropage_e2e_expected_log']); + + $unexpected = []; + $previous = false; + + foreach (intropage_e2e_log_findings($mark) as $line) { + $expected = false; + + foreach ($allowed as $pattern) { + if (strpos($line, $pattern) !== false) { + $expected = true; + } + } + + // The backtrace line that follows an expected PHP warning belongs to it + if (!$expected && $previous && strpos($line, 'Backtrace:') !== false) { + $expected = true; + } + + $previous = $expected; + + if (!$expected) { + $unexpected[] = $line; + } + } + + intropage_e2e_log_restore($mark); + + expect($unexpected)->toBe([], 'unexpected lines in cacti.log'); +}); + +/* ------------------------------------------------------------------ */ +/* panel permission on reload, details and add */ +/* ------------------------------------------------------------------ */ + +describe('reloading and describing panels', function () { + it('serves a panel the user was granted', function () { + $row = intropage_e2e_seed_panel('e2e_viewer', 'alert_host'); + + $reload = intropage_e2e_client('e2e_viewer')->get("action=reload&force=true&header=false&panel_id=$row"); + $detail = intropage_e2e_client('e2e_viewer')->get("action=details&header=false&panel_id=$row"); + + expect($reload['body'])->toContain('ccCardHeader'); + expect($detail['body'])->toContain('cactiTableTitle'); + }); + + it('refuses a system panel the user was not granted', function () { + $row = intropage_e2e_panel_row('analyse_db', 0); + + if ($row === 0) { + $this->markTestSkipped('analyse_db is not registered on this installation.'); + } + + $client = intropage_e2e_client('e2e_viewer'); + + expect(trim($client->get("action=reload&force=true&header=false&panel_id=$row")['body']))->toBe('Panel not found'); + expect(trim($client->get("action=details&header=false&panel_id=$row")['body']))->toBe('Panel Not Found'); + }); + + it('refuses a panel of its own after the permission is withdrawn', function () { + $row = intropage_e2e_seed_panel('e2e_viewer', 'alert_host'); + + intropage_e2e_set_permissions('e2e_viewer', ['favourite_graph']); + + $client = intropage_e2e_client('e2e_viewer'); + + expect(trim($client->get("action=reload&header=false&panel_id=$row")['body']))->toBe('Panel not found'); + expect(trim($client->get("action=details&header=false&panel_id=$row")['body']))->toBe('Panel Not Found'); + }); + + it('does not serve the panel row of another user', function () { + $row = intropage_e2e_seed_panel('e2e_other', 'alert_host'); + + $client = intropage_e2e_client('e2e_viewer'); + + expect($client->get("action=reload&header=false&panel_id=$row")['body'])->toContain('Panel not found'); + expect(trim($client->get("action=details&header=false&panel_id=$row")['body']))->toBe('Panel Not Found'); + }); + + it('reports a panel row that does not exist', function () { + $client = intropage_e2e_client('e2e_viewer'); + + expect($client->get('action=reload&header=false&panel_id=2147483000')['body'])->toContain('Panel not found'); + expect(trim($client->get('action=details&header=false&panel_id=2147483000')['body']))->toBe('Panel Not Found'); + }); + + it('always serves the admin alert panel, which has no permission entry', function () { + $row = intropage_e2e_panel_row('admin_alert', 0); + + if ($row === 0) { + $this->markTestSkipped('admin_alert is not registered on this installation.'); + } + + intropage_e2e_set_permissions('e2e_viewer', []); + + $reload = intropage_e2e_client('e2e_viewer')->get("action=reload&header=false&panel_id=$row"); + + expect($reload['body'])->not->toContain('Panel not found'); + expect($reload['body'])->toContain('ccCardHeader'); + }); + + it('rejects a non-numeric panel id', function () { + $result = intropage_e2e_client('e2e_viewer')->get('action=reload&header=false&panel_id=abc'); + + expect($result['body'])->not->toContain('ccCardHeader'); + + intropage_e2e_expect_log('input', 'Validation', 'validation'); + }); + + it('sends an unauthenticated request to the login page', function () { + $row = intropage_e2e_seed_panel('e2e_viewer', 'alert_host'); + $client = new IntropageE2eClient(intropage_e2e_config()['base']); + + foreach (["action=reload&header=false&panel_id=$row", "action=details&header=false&panel_id=$row"] as $query) { + $result = $client->get($query); + + expect($result['body'])->not->toContain('ccCardHeader'); + expect($result['body'])->not->toContain('cactiTableTitle'); + } + }); +}); + +describe('adding panels', function () { + it('adds a granted panel by name and by row id', function () { + $client = intropage_e2e_client('e2e_viewer'); + $user = intropage_e2e_user_id('e2e_viewer'); + + $client->post(INTROPAGE_E2E_PATH, ['intropage_addpanel' => 'alert_host', 'dashboard_id' => '1', 'header' => 'false']); + + $row = intropage_e2e_panel_row('alert_host', $user); + + expect($row)->toBeGreaterThan(0); + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_panel_dashboard WHERE user_id = ? AND panel_id = ?', [$user, $row]))->toBe(1); + + intropage_e2e_exec('DELETE FROM plugin_intropage_panel_dashboard WHERE user_id = ?', [$user]); + + $client->post(INTROPAGE_E2E_PATH, ['intropage_addpanel' => (string) $row, 'dashboard_id' => '1', 'header' => 'false']); + + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_panel_dashboard WHERE user_id = ? AND panel_id = ?', [$user, $row]))->toBe(1); + }); + + it('creates nothing for a panel the user was not granted', function () { + $client = intropage_e2e_client('e2e_viewer'); + $user = intropage_e2e_user_id('e2e_viewer'); + $row = intropage_e2e_panel_row('analyse_db', 0); + $before = (int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_panel_data WHERE panel_id = ?', ['analyse_db']); + + $client->post(INTROPAGE_E2E_PATH, ['intropage_addpanel' => 'analyse_db', 'dashboard_id' => '1', 'header' => 'false']); + + if ($row > 0) { + $client->post(INTROPAGE_E2E_PATH, ['intropage_addpanel' => (string) $row, 'dashboard_id' => '1', 'header' => 'false']); + } + + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_panel_data WHERE panel_id = ?', ['analyse_db']))->toBe($before); + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_panel_dashboard WHERE user_id = ?', [$user]))->toBe(0); + }); + + it('ignores a panel name that has no definition', function () { + $user = intropage_e2e_user_id('e2e_viewer'); + + intropage_e2e_client('e2e_viewer')->post(INTROPAGE_E2E_PATH, ['intropage_addpanel' => 'no_such_panel', 'dashboard_id' => '1', 'header' => 'false']); + + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_panel_data WHERE panel_id = ?', ['no_such_panel']))->toBe(0); + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_panel_dashboard WHERE user_id = ?', [$user]))->toBe(0); + }); + + it('ignores a row id that does not exist', function () { + $user = intropage_e2e_user_id('e2e_viewer'); + + intropage_e2e_client('e2e_viewer')->post(INTROPAGE_E2E_PATH, ['intropage_addpanel' => '2147483000', 'dashboard_id' => '1', 'header' => 'false']); + + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_panel_dashboard WHERE user_id = ?', [$user]))->toBe(0); + }); + + it('does not link a panel to a dashboard id of zero', function () { + $user = intropage_e2e_user_id('e2e_viewer'); + $row = intropage_e2e_seed_panel('e2e_viewer', 'alert_host'); + + intropage_e2e_exec('DELETE FROM plugin_intropage_panel_dashboard WHERE user_id = ?', [$user]); + + intropage_e2e_client('e2e_viewer')->post(INTROPAGE_E2E_PATH, ['intropage_addpanel' => (string) $row, 'dashboard_id' => '0', 'header' => 'false']); + + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_panel_dashboard WHERE user_id = ?', [$user]))->toBe(0); + }); + + it('rejects a request without a CSRF token', function () { + $user = intropage_e2e_user_id('e2e_viewer'); + + intropage_e2e_client('e2e_viewer')->request('POST', INTROPAGE_E2E_PATH, ['intropage_addpanel' => 'alert_host', 'dashboard_id' => '1', 'header' => 'false']); + + expect(intropage_e2e_panel_row('alert_host', $user))->toBe(0); + }); + + it('rejects an unauthenticated request', function () { + $client = new IntropageE2eClient(intropage_e2e_config()['base']); + + $client->request('POST', INTROPAGE_E2E_PATH, ['intropage_addpanel' => 'alert_host', 'dashboard_id' => '1', 'header' => 'false']); + + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_panel_data WHERE panel_id = ? AND user_id = 0', ['alert_host']))->toBeGreaterThanOrEqual(0); + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_panel_dashboard WHERE user_id = ?', [intropage_e2e_user_id('e2e_viewer')]))->toBe(0); + }); + + it('does not add a panel through a GET request', function () { + $user = intropage_e2e_user_id('e2e_viewer'); + + intropage_e2e_client('e2e_viewer')->get('intropage_addpanel=alert_host&dashboard_id=1&header=false'); + + expect(intropage_e2e_panel_row('alert_host', $user))->toBe(0); + }); +}); + +/* ------------------------------------------------------------------ */ +/* actions that change data need POST */ +/* ------------------------------------------------------------------ */ + +/** + * Reads the stored state an action changes, for before and after checks. + */ +function intropage_e2e_probe(string $probe, int $user, int $row): string { + switch ($probe) { + case 'dashboards': + return (string) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_dashboard WHERE user_id = ?', [$user]); + case 'shared': + return (string) intropage_e2e_cell('SELECT SUM(shared) FROM plugin_intropage_dashboard WHERE user_id = ?', [$user]); + case 'login_opts': + return (string) intropage_e2e_cell('SELECT login_opts FROM user_auth WHERE id = ?', [$user]); + case 'height': + return (string) intropage_e2e_cell('SELECT height FROM plugin_intropage_panel_data WHERE id = ?', [$row]); + case 'panels': + return (string) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_panel_data WHERE user_id = ?', [$user]); + case 'favourites': + return (string) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_panel_data WHERE user_id = ? AND fav_graph_id IS NOT NULL', [$user]); + default: + return (string) intropage_e2e_cell('SELECT value FROM settings_user WHERE user_id = ? AND name = ?', [$user, $probe]); + } +} + +dataset('post only actions', [ + 'remove a dashboard' => [['intropage_action' => 'removepage_2'], 'dashboards'], + 'add a dashboard' => [['intropage_action' => 'addpage_1'], 'dashboards'], + 'share a dashboard' => [['intropage_action' => 'share'], 'shared'], + 'switch the login page' => [['intropage_action' => 'loginopt_graph'], 'login_opts'], + 'grow a panel' => [['intropage_action' => 'heightmore', 'panel_id' => '{row}'], 'height'], + 'drop a panel' => [['intropage_action' => 'droppanel', 'panel_id' => '{row}'], 'panels'], + 'favourite a graph' => [['intropage_action' => 'favgraph', 'graph_id' => '1'], 'favourites'], + 'set the number of lines' => [['intropage_action' => 'lines_15'], 'intropage_number_of_lines'], + 'set the important period' => [['intropage_action' => 'period_900'], 'intropage_important_period'], + 'set the refresh' => [['intropage_action' => 'refresh_300'], 'intropage_autorefresh'], + 'sort by severity' => [['intropage_action' => 'important_first'], 'intropage_display_important_first'], + 'use wider panels' => [['intropage_action' => 'displaywide_on'], 'intropage_display_wide'], + 'change the timespan' => [['intropage_action_timespan' => 'timespan_7200'], 'intropage_timespan'], +]); + +describe('actions that change data', function () { + beforeEach(function () { + $this->user = intropage_e2e_user_id('e2e_viewer'); + $this->row = intropage_e2e_seed_panel('e2e_viewer', 'alert_host'); + + intropage_e2e_exec('INSERT INTO plugin_intropage_dashboard (user_id, dashboard_id, name, shared) VALUES (?, 2, ?, 0)', [$this->user, 'Second']); + intropage_e2e_exec('DELETE FROM user_auth_realm WHERE user_id = ? AND realm_id = 7', [$this->user]); + + // favgraph reads the graph timespan that Cacti's graph pages keep in the session + intropage_e2e_expect_log('sess_current_timespan'); + }); + + it('ignores the action as GET and applies it as POST', function (array $fields, string $probe) { + $client = intropage_e2e_client('e2e_viewer'); + $fields = array_map(fn ($value) => $value === '{row}' ? (string) $this->row : $value, $fields); + $before = intropage_e2e_probe($probe, $this->user, $this->row); + + $client->get(http_build_query($fields + ['header' => 'false'])); + + expect(intropage_e2e_probe($probe, $this->user, $this->row))->toBe($before); + + $client->post(INTROPAGE_E2E_PATH, $fields + ['header' => 'false']); + + expect(intropage_e2e_probe($probe, $this->user, $this->row))->not->toBe($before); + })->with('post only actions'); + + it('ignores the action when the CSRF token is missing', function (array $fields, string $probe) { + $fields = array_map(fn ($value) => $value === '{row}' ? (string) $this->row : $value, $fields); + $before = intropage_e2e_probe($probe, $this->user, $this->row); + + intropage_e2e_client('e2e_viewer')->request('POST', INTROPAGE_E2E_PATH, $fields + ['header' => 'false']); + + expect(intropage_e2e_probe($probe, $this->user, $this->row))->toBe($before); + })->with('post only actions'); + + it('ignores the action from an unauthenticated client', function (array $fields, string $probe) { + $fields = array_map(fn ($value) => $value === '{row}' ? (string) $this->row : $value, $fields); + $before = intropage_e2e_probe($probe, $this->user, $this->row); + $client = new IntropageE2eClient(intropage_e2e_config()['base']); + + $client->request('POST', INTROPAGE_E2E_PATH, $fields + ['header' => 'false']); + $client->get(http_build_query($fields + ['header' => 'false'])); + + expect(intropage_e2e_probe($probe, $this->user, $this->row))->toBe($before); + })->with('post only actions'); + + it('still serves the read-only panel select as GET', function () { + $result = intropage_e2e_client('e2e_viewer')->get('intropage_action=addpanelselect&dashboard_id=1&header=false'); + + expect($result['body'])->toContain('intropage_addpanel'); + }); + + it('does not switch the login option for a GET request', function () { + intropage_e2e_client('e2e_viewer')->get('intropage_action=loginopt_console&header=false'); + + expect(intropage_e2e_probe('login_opts', $this->user, $this->row))->toBe('4'); + }); + + it('saves the login option for a POST request', function () { + intropage_e2e_client('e2e_viewer')->post(INTROPAGE_E2E_PATH, ['intropage_action' => 'loginopt_console', 'header' => 'false']); + + expect(intropage_e2e_probe('login_opts', $this->user, $this->row))->toBe('2'); + }); +}); + +/* ------------------------------------------------------------------ */ +/* dashboards */ +/* ------------------------------------------------------------------ */ + +dataset('dashboard names', [ + 'plain' => ['Core routers', 'Core routers'], + 'single quote' => ["O'Brien", 'O'Brien'], + 'double quote' => ['The "main" board', 'The "main" board'], + 'markup' => ['x&', '<b>x</b>&'], + 'unicode' => ["D\u{e9}faut \u{20ac}", "D\u{e9}faut \u{20ac}"], + 'thirty characters' => [str_repeat('n', 30), str_repeat('n', 30)], +]); + +describe('dashboard names', function () { + it('stores the name, keeps the shared flag and escapes the name on output', function (string $name, string $html) { + $client = intropage_e2e_client('e2e_viewer'); + $user = intropage_e2e_user_id('e2e_viewer'); + + intropage_e2e_exec('UPDATE plugin_intropage_dashboard SET shared = 1 WHERE user_id = ? AND dashboard_id = 1', [$user]); + + $client->post(INTROPAGE_E2E_PATH, ['header' => 'false', 'action' => 'save', 'save_settings' => '1', 'name_1' => $name]); + + expect(intropage_e2e_cell('SELECT name FROM plugin_intropage_dashboard WHERE user_id = ? AND dashboard_id = 1', [$user]))->toBe($name); + expect((int) intropage_e2e_cell('SELECT shared FROM plugin_intropage_dashboard WHERE user_id = ? AND dashboard_id = 1', [$user]))->toBe(1); + + $page = $client->get(); + + expect($page['body'])->toContain('>' . $html . ''); + + if ($html !== $name) { + expect($page['body'])->not->toContain('>' . $name . ''); + } + })->with('dashboard names'); + + it('never creates a dashboard the user does not have', function (string $field) { + $user = intropage_e2e_user_id('e2e_viewer'); + + intropage_e2e_client('e2e_viewer')->post(INTROPAGE_E2E_PATH, ['header' => 'false', 'action' => 'save', 'save_settings' => '1', $field => 'Never created', 'name_1' => 'Kept']); + + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_dashboard WHERE user_id = ?', [$user]))->toBe(1); + expect(intropage_e2e_cell('SELECT name FROM plugin_intropage_dashboard WHERE user_id = ? AND dashboard_id = 1', [$user]))->toBe('Kept'); + })->with(['unused id' => ['name_9'], 'not an integer' => ['name_x'], 'leading zero' => ['name_03'], 'exponent' => ['name_1e3'], 'negative' => ['name_-1']]); + + it('ignores a name posted as a list', function () { + $user = intropage_e2e_user_id('e2e_viewer'); + + intropage_e2e_client('e2e_viewer')->post(INTROPAGE_E2E_PATH, ['header' => 'false', 'action' => 'save', 'save_settings' => '1', 'name_1' => ['a', 'b']]); + + expect(intropage_e2e_cell('SELECT name FROM plugin_intropage_dashboard WHERE user_id = ? AND dashboard_id = 1', [$user]))->toBe('Default'); + }); + + it('does not rename dashboards of another user', function () { + intropage_e2e_client('e2e_viewer')->post(INTROPAGE_E2E_PATH, ['header' => 'false', 'action' => 'save', 'save_settings' => '1', 'name_1' => 'Mine']); + + expect(intropage_e2e_cell('SELECT name FROM plugin_intropage_dashboard WHERE user_id = ? AND dashboard_id = 1', [intropage_e2e_user_id('e2e_other')]))->toBe('Default'); + }); + + it('does not rename through a GET request', function () { + $user = intropage_e2e_user_id('e2e_viewer'); + + // Cacti core itself logs a warning for this GET + intropage_e2e_expect_log('Attempt to use GET method'); + + intropage_e2e_client('e2e_viewer')->get('header=false&action=save&save_settings=1&name_1=Changed'); + + expect(intropage_e2e_cell('SELECT name FROM plugin_intropage_dashboard WHERE user_id = ? AND dashboard_id = 1', [$user]))->toBe('Default'); + }); +}); + +describe('dashboards', function () { + it('adds, shares, unshares and removes a dashboard', function () { + $client = intropage_e2e_client('e2e_viewer'); + $user = intropage_e2e_user_id('e2e_viewer'); + + $client->post(INTROPAGE_E2E_PATH, ['intropage_action' => 'addpage_1', 'header' => 'false']); + + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_dashboard WHERE user_id = ? AND dashboard_id = 2', [$user]))->toBe(1); + + $client->post(INTROPAGE_E2E_PATH, ['intropage_action' => 'share', 'header' => 'false']); + expect((int) intropage_e2e_cell('SELECT SUM(shared) FROM plugin_intropage_dashboard WHERE user_id = ?', [$user]))->toBe(1); + + $client->post(INTROPAGE_E2E_PATH, ['intropage_action' => 'unshare', 'header' => 'false']); + expect((int) intropage_e2e_cell('SELECT SUM(shared) FROM plugin_intropage_dashboard WHERE user_id = ?', [$user]))->toBe(0); + + $client->post(INTROPAGE_E2E_PATH, ['intropage_action' => 'removepage_2', 'header' => 'false']); + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_dashboard WHERE user_id = ? AND dashboard_id = 2', [$user]))->toBe(0); + }); + + it('copies a shared dashboard of another user', function () { + $owner = intropage_e2e_user_id('e2e_other'); + $user = intropage_e2e_user_id('e2e_viewer'); + + intropage_e2e_seed_panel('e2e_other', 'alert_host'); + intropage_e2e_exec('UPDATE plugin_intropage_dashboard SET shared = 1 WHERE user_id = ?', [$owner]); + + intropage_e2e_client('e2e_viewer')->post(INTROPAGE_E2E_PATH, ['intropage_action' => "useshared_1_$owner", 'header' => 'false']); + + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_dashboard WHERE user_id = ?', [$user]))->toBe(2); + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_panel_data WHERE user_id = ?', [$user]))->toBe(1); + }); + + it('refuses to copy a dashboard that is not shared', function () { + $owner = intropage_e2e_user_id('e2e_other'); + $user = intropage_e2e_user_id('e2e_viewer'); + + intropage_e2e_client('e2e_viewer')->post(INTROPAGE_E2E_PATH, ['intropage_action' => "useshared_1_$owner", 'header' => 'false']); + + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_dashboard WHERE user_id = ?', [$user]))->toBe(1); + }); +}); + +/* ------------------------------------------------------------------ */ +/* panels that are removed when the permission is withdrawn */ +/* ------------------------------------------------------------------ */ + +describe('withdrawn panels', function () { + it('removes the dashboard link and the data row of a withdrawn panel', function () { + $user = intropage_e2e_user_id('e2e_viewer'); + + intropage_e2e_seed_panel('e2e_viewer', 'alert_host'); + intropage_e2e_set_permissions('e2e_viewer', ['favourite_graph']); + intropage_e2e_expect_log('One or more panels was removed'); + + intropage_e2e_client('e2e_viewer')->get(); + + expect(intropage_e2e_panel_row('alert_host', $user))->toBe(0); + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_panel_dashboard WHERE user_id = ?', [$user]))->toBe(0); + }); + + it('keeps the panels that are still permitted and the system rows', function () { + $user = intropage_e2e_user_id('e2e_viewer'); + $system = (int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_panel_data WHERE user_id = 0'); + + intropage_e2e_seed_panel('e2e_viewer', 'alert_host'); + intropage_e2e_seed_panel('e2e_viewer', 'graph_host_template'); + intropage_e2e_set_permissions('e2e_viewer', ['alert_host', 'favourite_graph']); + intropage_e2e_expect_log('One or more panels was removed'); + + intropage_e2e_client('e2e_viewer')->get(); + + expect(intropage_e2e_panel_row('alert_host', $user))->toBeGreaterThan(0); + expect(intropage_e2e_panel_row('graph_host_template', $user))->toBe(0); + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_panel_data WHERE user_id = 0'))->toBe($system); + }); + + it('leaves other dashboards and users alone when nothing is withdrawn', function () { + $user = intropage_e2e_user_id('e2e_viewer'); + + intropage_e2e_seed_panel('e2e_viewer', 'alert_host'); + intropage_e2e_seed_panel('e2e_other', 'alert_host'); + + intropage_e2e_client('e2e_viewer')->get(); + + expect(intropage_e2e_panel_row('alert_host', $user))->toBeGreaterThan(0); + expect(intropage_e2e_panel_row('alert_host', intropage_e2e_user_id('e2e_other')))->toBeGreaterThan(0); + }); +}); + +/* ------------------------------------------------------------------ */ +/* chart labels */ +/* ------------------------------------------------------------------ */ + +dataset('hostile template names', [ + 'quote breakout' => ["e2e-',0]];m();//", "e2e-" . chr(92) . 'u0027,0]];m();' . chr(92) . '/' . chr(92) . '/'], + 'script end tag' => ['e2e-', 'e2e-' . chr(92) . 'u003C' . chr(92) . '/script' . chr(92) . 'u003E'], + 'double quote' => ['e2e-"q"&', 'e2e-' . chr(92) . 'u0022q' . chr(92) . 'u0022' . chr(92) . 'u0026'], + 'backslash' => ['e2e-a' . chr(92) . 'b', 'e2e-a' . chr(92) . chr(92) . 'b'], + 'cut inside a character' => [str_repeat("\u{20ac}", 8), chr(92) . 'u20ac' . chr(92) . 'ufffd"'], +]); + +describe('chart labels', function () { + it('writes device template names into the chart as JSON strings', function (string $name, string $expected) { + $client = intropage_e2e_client('e2e_viewer'); + + intropage_e2e_exec('INSERT INTO host_template (hash, name) VALUES (?, ?)', [bin2hex(random_bytes(16)), $name]); + + // The panel lists the 20 templates with the most devices, so give this one a device + $template = (int) intropage_e2e_cell('SELECT MAX(id) FROM host_template WHERE name = ?', [$name]); + + intropage_e2e_exec('INSERT INTO host (host_template_id, description, hostname) VALUES (?, ?, ?)', [$template, 'e2e-device', 'e2e-device.invalid']); + + $row = intropage_e2e_seed_panel('e2e_viewer', 'graph_host_template'); + + $reload = $client->get("action=reload&force=true&header=false&panel_id=$row"); + + expect($reload['body'])->toContain($expected); + expect($reload['body'])->not->toContain("['" . substr($name, 0, 20)); + expect($reload['body'])->not->toContain(''); + })->with('hostile template names'); +}); + +/* ------------------------------------------------------------------ */ +/* console_after and the device scope */ +/* ------------------------------------------------------------------ */ + +describe('dashboard in the console', function () { + it('renders the dashboard for a console user with the viewer realm', function () { + intropage_e2e_reset('e2e_console'); + intropage_e2e_set_permissions('e2e_console', ['alert_host']); + + $page = intropage_e2e_client('e2e_console')->request('GET', '/index.php'); + + expect($page['body'])->toContain('intropage_main'); + expect($page['body'])->toContain(''); + }); + + it('renders the console without a dashboard for a user without the viewer realm', function () { + $page = intropage_e2e_client('e2e_noplugin')->request('GET', '/index.php'); + + expect($page['status'])->toBe(200); + expect($page['body'])->not->toContain('intropage_main'); + expect($page['body'])->toContain(''); + }); +}); + +describe('device scope', function () { + it('keeps the hide_disabled preference of a user with restricted devices', function () { + $user = intropage_e2e_user_id('e2e_viewer'); + $row = intropage_e2e_seed_panel('e2e_viewer', 'alert_host'); + + intropage_e2e_exec('UPDATE user_auth SET policy_hosts = 2 WHERE id = ?', [$user]); + intropage_e2e_exec('INSERT INTO settings_user (user_id, name, value) VALUES (?, ?, ?)', [$user, 'hide_disabled', 'on']); + + $reload = intropage_e2e_client('e2e_viewer')->get("action=reload&force=true&header=false&panel_id=$row"); + + intropage_e2e_exec('UPDATE user_auth SET policy_hosts = 1 WHERE id = ?', [$user]); + + expect($reload['body'])->toContain('ccCardHeader'); + expect(intropage_e2e_cell('SELECT value FROM settings_user WHERE user_id = ? AND name = ?', [$user, 'hide_disabled']))->toBe('on'); + }); +}); + +/* ------------------------------------------------------------------ */ +/* poller */ +/* ------------------------------------------------------------------ */ + +describe('poller', function () { + it('creates the data row of every system panel that has none', function () { + $panels = array_column(intropage_e2e_rows('SELECT d.panel_id FROM plugin_intropage_panel_definition AS d INNER JOIN plugin_intropage_panel_data AS p ON p.panel_id = d.panel_id AND p.user_id = 0 WHERE d.level = 0 AND d.requires = ? ORDER BY d.panel_id LIMIT 2', ['']), 'panel_id'); + + if (count($panels) < 2) { + $this->markTestSkipped('Fewer than two dependency-free system panels are registered.'); + } + + foreach ($panels as $panel) { + intropage_e2e_exec('DELETE FROM plugin_intropage_panel_data WHERE panel_id = ? AND user_id = 0', [$panel]); + } + + $run = intropage_e2e_poller(); + + expect($run['output'])->not->toContain('TypeError'); + expect($run['status'])->toBe(0); + + foreach ($panels as $panel) { + expect(intropage_e2e_panel_row($panel, 0))->toBeGreaterThan(0); + } + }); + + it('refreshes a user panel and leaves the system rows owned by user 0', function () { + $user = intropage_e2e_user_id('e2e_viewer'); + $row = intropage_e2e_seed_panel('e2e_viewer', 'alert_host'); + + intropage_e2e_exec('UPDATE plugin_intropage_panel_data SET last_update = ? WHERE id = ?', ['2000-01-01 00:00:00', $row]); + + $run = intropage_e2e_poller(); + + expect($run['status'])->toBe(0); + expect(intropage_e2e_cell('SELECT last_update FROM plugin_intropage_panel_data WHERE id = ?', [$row]))->not->toBe('2000-01-01 00:00:00'); + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_panel_data WHERE user_id = 0 AND panel_id = ?', ['alert_host']))->toBe(0); + expect((int) intropage_e2e_cell('SELECT user_id FROM plugin_intropage_panel_data WHERE id = ?', [$row]))->toBe($user); + }); + + 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']); + + $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'); + + 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]); + + // 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 () { + $row = intropage_e2e_seed_panel('e2e_viewer', 'alert_host'); + + intropage_e2e_exec('UPDATE plugin_intropage_panel_data SET last_update = NOW(), refresh_interval = 86400 WHERE id = ?', [$row]); + + $before = intropage_e2e_cell('SELECT last_update FROM plugin_intropage_panel_data WHERE id = ?', [$row]); + + expect(intropage_e2e_poller()['status'])->toBe(0); + expect(intropage_e2e_cell('SELECT last_update FROM plugin_intropage_panel_data WHERE id = ?', [$row]))->toBe($before); + }); +}); + +/* ------------------------------------------------------------------ */ +/* user groups */ +/* ------------------------------------------------------------------ */ + +describe('user groups', function () { + it('deletes the permission row of a removed group and keeps the others', function () { + intropage_e2e_exec('DELETE FROM user_auth_group WHERE name LIKE ?', ['e2e-%']); + + foreach (['e2e-doomed', 'e2e-kept'] as $name) { + intropage_e2e_exec('INSERT INTO user_auth_group (name, description, graph_settings, login_opts, show_tree, show_list, show_preview, policy_graphs, policy_trees, policy_hosts, policy_graph_templates, enabled) VALUES (?, ?, ?, 1, ?, ?, ?, 1, 1, 1, 1, ?)', [$name, $name, 'on', 'on', 'on', 'on', 'on']); + } + + $doomed = (int) intropage_e2e_cell('SELECT id FROM user_auth_group WHERE name = ?', ['e2e-doomed']); + $kept = (int) intropage_e2e_cell('SELECT id FROM user_auth_group WHERE name = ?', ['e2e-kept']); + + foreach ([$doomed, $kept] as $group) { + intropage_e2e_exec('REPLACE INTO plugin_intropage_user_group_auth (user_group_id, permissions) VALUES (?, ?)', [$group, json_encode(['alert_host' => 'on'])]); + } + + $result = intropage_e2e_client('e2e_admin')->post('/user_group_admin.php', [ + 'action' => 'actions', + 'drp_action' => '1', + 'selected_items' => serialize([(string) $doomed]), + ]); + + expect($result['status'])->toBe(302); + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM user_auth_group WHERE id = ?', [$doomed]))->toBe(0); + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_user_group_auth WHERE user_group_id = ?', [$doomed]))->toBe(0); + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_user_group_auth WHERE user_group_id = ?', [$kept]))->toBe(1); + + intropage_e2e_exec('DELETE FROM plugin_intropage_user_group_auth WHERE user_group_id = ?', [$kept]); + }); + + it('does not let a user without user management delete a group', function () { + intropage_e2e_exec('INSERT INTO user_auth_group (name, description, graph_settings, login_opts, show_tree, show_list, show_preview, policy_graphs, policy_trees, policy_hosts, policy_graph_templates, enabled) VALUES (?, ?, ?, 1, ?, ?, ?, 1, 1, 1, 1, ?)', ['e2e-safe', 'e2e-safe', 'on', 'on', 'on', 'on', 'on']); + + $group = (int) intropage_e2e_cell('SELECT id FROM user_auth_group WHERE name = ?', ['e2e-safe']); + + intropage_e2e_client('e2e_viewer')->post('/user_group_admin.php', ['action' => 'actions', 'drp_action' => '1', 'selected_items' => serialize([(string) $group])]); + + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM user_auth_group WHERE id = ?', [$group]))->toBe(1); + }); +}); + +/* ------------------------------------------------------------------ */ +/* favourite graphs */ +/* ------------------------------------------------------------------ */ + +describe('favourite graphs', function () { + afterEach(function () { + if (intropage_e2e_config() !== null) { + intropage_e2e_drop_graphs(); + } + }); + + it('stores a favourite graph and renders it with its title', function () { + [, $graph] = intropage_e2e_seed_graph('e2e favourite graph'); + + $client = intropage_e2e_client('e2e_viewer'); + $user = intropage_e2e_user_id('e2e_viewer'); + + // 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'); + + $client->post(INTROPAGE_E2E_PATH, ['intropage_action' => 'favgraph', 'graph_id' => (string) $graph, 'header' => 'false']); + + $row = (int) intropage_e2e_cell('SELECT id FROM plugin_intropage_panel_data WHERE user_id = ? AND fav_graph_id = ?', [$user, $graph]); + + expect($row)->toBeGreaterThan(0); + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_panel_dashboard WHERE user_id = ? AND panel_id = ?', [$user, $row]))->toBe(1); + + $reload = $client->get("action=reload&header=false&panel_id=$row"); + + expect($reload['body'])->toContain('graph_image.php?local_graph_id=' . $graph); + expect($reload['body'])->toContain('e2e favourite graph'); + }); + + 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_set_permissions('e2e_viewer', ['alert_host']); + intropage_e2e_expect_log('sess_current_timespan'); + + $user = intropage_e2e_user_id('e2e_viewer'); + + intropage_e2e_client('e2e_viewer')->post(INTROPAGE_E2E_PATH, ['intropage_action' => 'favgraph', 'graph_id' => (string) $graph, 'header' => 'false']); + + $row = (int) intropage_e2e_cell('SELECT id FROM plugin_intropage_panel_data WHERE user_id = ? AND fav_graph_id = ?', [$user, $graph]); + + // The row is stored, but the dashboard drops it as a withdrawn panel when it renders + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM plugin_intropage_panel_dashboard WHERE user_id = ? AND panel_id = ?', [$user, $row]))->toBe(0); + + intropage_e2e_expect_log('One or more panels was removed'); + }); +}); + +/** + * Runs the graph_buttons hook through Cacti for a user and returns the output. + */ +function intropage_e2e_graph_hook(int $user, int $graph, ?int $timespan = null): string { + $config = intropage_e2e_config(); + $php = escapeshellarg(PHP_BINARY); + $env = 'INTROPAGE_E2E_CACTI=' . escapeshellarg($config['cacti']) . ' INTROPAGE_E2E_HOOK_USER=' . $user . ' INTROPAGE_E2E_HOOK_GRAPH=' . $graph; + + if ($timespan !== null) { + $env .= ' INTROPAGE_E2E_HOOK_TIMESPAN=' . $timespan; + } + + if ($config['cov'] !== '') { + $env .= ' INTROPAGE_COVERAGE_DIR=' . escapeshellarg($config['cov']); + $php .= ' -d memory_limit=1G -d xdebug.mode=coverage -d auto_prepend_file=' . escapeshellarg(__DIR__ . '/coverage-prepend.php'); + } + + return (string) shell_exec($env . ' ' . $php . ' ' . escapeshellarg(__DIR__ . '/hook-runner.php') . ' 2>&1'); +} + +describe('graph button hook', function () { + afterEach(function () { + if (intropage_e2e_config() !== null) { + intropage_e2e_drop_graphs(); + } + }); + + it('adds a button that posts, and its script only once', function () { + [, $graph] = intropage_e2e_seed_graph('e2e hook graph'); + + $output = intropage_e2e_graph_hook(intropage_e2e_user_id('e2e_viewer'), $graph, 5); + $calls = explode('--- call 2', $output); + + expect($calls[0])->toContain('class="iconLink intropage_favgraph"'); + expect($calls[0])->toContain('data-graph="' . $graph . '"'); + expect($calls[0])->toContain('title="Add to Dashboard"'); + expect($calls[0])->not->toContain('intropage_action=favgraph'); + expect(substr_count($calls[0], 'toBe(1); + expect($calls[1])->toContain('data-graph="' . $graph . '"'); + expect($calls[1])->not->toContain('toContain('title="Remove from Dashboard"'); + }); + + it('explains that a custom timespan cannot be added', function () { + [, $graph] = intropage_e2e_seed_graph('e2e hook graph'); + + $output = intropage_e2e_graph_hook(intropage_e2e_user_id('e2e_viewer'), $graph, 0); + + expect($output)->toContain('Cannot add to Dashboard. Custom timespan.'); + }); + + it('adds no button for a user without the favourite graph permission', function () { + [, $graph] = intropage_e2e_seed_graph('e2e hook graph'); + + intropage_e2e_set_permissions('e2e_viewer', ['alert_host']); + + $output = intropage_e2e_graph_hook(intropage_e2e_user_id('e2e_viewer'), $graph, 5); + + expect($output)->not->toContain('intropage_favgraph'); + expect($output)->not->toContain('&1'); + + expect($output)->toBe(''); + }); +}); + +/* ------------------------------------------------------------------ */ +/* threshold events with several users */ +/* ------------------------------------------------------------------ */ + +describe('threshold events', function () { + it('lists an event once however many users hold a matching permission', function () { + require_once __DIR__ . '/../../includes/functions.php'; + require_once __DIR__ . '/../../panellib/thold.php'; + + $ddl = [ + 'thold_data' => 'CREATE TABLE thold_data (id INT AUTO_INCREMENT PRIMARY KEY, local_graph_id INT NOT NULL DEFAULT 0, host_id INT NOT NULL DEFAULT 0)', + 'plugin_thold_log' => 'CREATE TABLE plugin_thold_log (id INT AUTO_INCREMENT PRIMARY KEY, threshold_id INT NOT NULL DEFAULT 0, description VARCHAR(255) NOT NULL DEFAULT \'\', time INT NOT NULL DEFAULT 0, status INT NOT NULL DEFAULT 0)', + ]; + + foreach (array_keys($ddl) as $table) { + if ((int) intropage_e2e_cell('SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?', [$table]) > 0) { + $this->markTestSkipped('The Thold tables exist on this installation; not touching them.'); + } + } + + foreach ($ddl as $create) { + intropage_e2e_db()->exec($create); + } + + try { + [$host, $graph] = intropage_e2e_seed_graph('e2e threshold graph'); + + intropage_e2e_exec('INSERT INTO thold_data (local_graph_id, host_id) VALUES (?, ?)', [$graph, $host]); + + $threshold = (int) intropage_e2e_cell('SELECT MAX(id) FROM thold_data'); + + intropage_e2e_exec('INSERT INTO plugin_thold_log (threshold_id, description, time, status) VALUES (?, ?, ?, 1)', [$threshold, 'e2e threshold ALERT', time()]); + + foreach (['e2e_viewer', 'e2e_other', 'e2e_admin'] as $user) { + intropage_e2e_exec('INSERT INTO user_auth_perms (user_id, item_id, type) VALUES (?, ?, 3)', [intropage_e2e_user_id($user), $host]); + } + + // Run the panel's own query with the stubbed database layer of the unit bootstrap, then on the real database + $GLOBALS['__test_enabled_plugins'] = ['thold' => true]; + $GLOBALS['__test_simple_perms'] = true; + $GLOBALS['__test_events'] = []; + $GLOBALS['__test_query'] = function ($kind, $sql, $params) { + if (strpos($sql, 'FROM plugin_thold_log') !== false) { + $GLOBALS['__test_events'][] = ['sql' => $sql, 'params' => $params]; + } + + return $kind === 'assoc' ? [] : ''; + }; + + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = sys_get_temp_dir() . '/intropage-thold-e2e-' . bin2hex(random_bytes(6)); + + $_SESSION['sess_user_id'] = intropage_e2e_user_id('e2e_viewer'); + + thold_event(['id' => 1, 'height' => 'normal', 'data' => '', 'alarm' => 'green'], intropage_e2e_user_id('e2e_viewer')); + thold_event_detail(); + + $GLOBALS['config']['base_path'] = $restore; + + unset($GLOBALS['__test_query'], $GLOBALS['__test_enabled_plugins'], $GLOBALS['__test_simple_perms']); + + expect($GLOBALS['__test_events'])->toHaveCount(2); + + foreach ($GLOBALS['__test_events'] as $event) { + expect(intropage_e2e_rows($event['sql'], $event['params']))->toHaveCount(1); + + // Without the user condition the same query returns the row once per user holding a permission + $unfixed = preg_replace('/ AND uap\d\.user_id = \?/', '', $event['sql']); + + expect(intropage_e2e_rows($unfixed))->toHaveCount(3); + } + } finally { + intropage_e2e_exec('DELETE FROM user_auth_perms WHERE type = 3 AND user_id IN (SELECT id FROM user_auth WHERE username LIKE ?)', ['e2e_%']); + intropage_e2e_drop_graphs(); + intropage_e2e_db()->exec('DROP TABLE plugin_thold_log'); + intropage_e2e_db()->exec('DROP TABLE thold_data'); + } + }); +}); + +/* ------------------------------------------------------------------ */ +/* upgrade from an old version */ +/* ------------------------------------------------------------------ */ + +/** + * Rewrites the plugin tables into their 3.0.0 shape, with per-panel columns + * in the user authorisation table, and stores the old version. + */ +function intropage_e2e_make_legacy_schema(int $user, int $other): void { + foreach ([ + 'ALTER TABLE plugin_intropage_user_auth DROP COLUMN permissions, ADD COLUMN alert_host CHAR(2) NOT NULL DEFAULT \'on\', ADD COLUMN retired_panel CHAR(2) NOT NULL DEFAULT \'on\'', + 'ALTER TABLE plugin_intropage_dashboard DROP COLUMN shared', + 'ALTER TABLE plugin_intropage_panel_data DROP COLUMN height, DROP COLUMN trend_interval, DROP COLUMN last_trend_update', + 'ALTER TABLE plugin_intropage_panel_definition DROP COLUMN height, DROP COLUMN trefresh', + 'ALTER TABLE plugin_intropage_panel_dashboard DROP PRIMARY KEY', + 'DROP TABLE plugin_intropage_user_group_auth', + ] as $alter) { + intropage_e2e_exec($alter); + } + + intropage_e2e_exec('DELETE FROM plugin_intropage_user_auth'); + intropage_e2e_exec('INSERT INTO plugin_intropage_user_auth (user_id, login_opts, alert_host, retired_panel) VALUES (?, 0, ?, ?)', [$user, 'on', 'on']); + intropage_e2e_exec('INSERT INTO plugin_intropage_user_auth (user_id, login_opts, alert_host, retired_panel) VALUES (?, 0, ?, ?)', [$other, '', 'on']); + intropage_e2e_exec('UPDATE plugin_config SET version = ? WHERE directory = ?', ['3.0.0', 'intropage']); +} + +/** + * Builds a throwaway copy of the Cacti root for the upgrade runner: every + * entry is a link to the real one, except plugins/intropage, which holds only + * the files the upgrade reads or prunes (the library and panel directories + * are links). Cacti's own plugin code resolves paths through base_path, so + * the core directories have to be reachable below the copy. + */ +function intropage_e2e_throwaway_tree(string $cacti): string { + $tree = sys_get_temp_dir() . '/intropage-upgrade-e2e-' . bin2hex(random_bytes(6)); + + mkdir($tree . '/plugins/intropage', 0700, true); + + foreach (array_diff(scandir($cacti), ['.', '..', 'plugins']) as $entry) { + symlink($cacti . '/' . $entry, $tree . '/' . $entry); + } + + foreach (array_diff(scandir($cacti . '/plugins'), ['.', '..', 'intropage']) as $entry) { + symlink($cacti . '/plugins/' . $entry, $tree . '/plugins/' . $entry); + } + + $real = $cacti . '/plugins/intropage'; + + copy($real . '/INFO', $tree . '/plugins/intropage/INFO'); + symlink($real . '/includes', $tree . '/plugins/intropage/includes'); + symlink($real . '/panellib', $tree . '/plugins/intropage/panellib'); + symlink($real . '/setup.php', $tree . '/plugins/intropage/setup.php'); + file_put_contents($tree . '/plugins/intropage/manifest.json', json_encode(['tombstones' => [], 'expected' => ['INFO', 'includes/', 'manifest.json', 'panellib/', 'setup.php'], 'whitelist' => []])); + + return $tree; +} + +describe('upgrade', function () { + it('converts a 3.0.0 installation and restores the current schema', function () { + require_once __DIR__ . '/../../setup.php'; + + $config = intropage_e2e_config(); + $viewer = intropage_e2e_user_id('e2e_viewer'); + $other = intropage_e2e_user_id('e2e_other'); + $current = (string) parse_ini_file(dirname(__DIR__, 2) . '/INFO', true)['info']['version']; + $plugin = dirname(__DIR__, 2); + $tree = intropage_e2e_throwaway_tree($config['cacti']); + + intropage_e2e_make_legacy_schema($viewer, $other); + + $php = escapeshellarg(PHP_BINARY); + $env = 'INTROPAGE_E2E_CACTI=' . escapeshellarg($config['cacti']) . ' INTROPAGE_E2E_UPGRADE_BASE=' . escapeshellarg($tree); + + if ($config['cov'] !== '') { + $env .= ' INTROPAGE_COVERAGE_DIR=' . escapeshellarg($config['cov']); + $php .= ' -d memory_limit=1G -d xdebug.mode=coverage -d auto_prepend_file=' . escapeshellarg(__DIR__ . '/coverage-prepend.php'); + } + + $output = []; + $status = 1; + + exec($env . ' ' . $php . ' ' . escapeshellarg(__DIR__ . '/upgrade-runner.php') . ' 2>&1', $output, $status); + + // The setup.php helper removes links without following them + plugin_intropage_rmtree($tree); + + expect(implode("\n", $output))->toContain('upgrade finished'); + expect($status)->toBe(0); + expect(intropage_e2e_cell('SELECT version FROM plugin_config WHERE directory = ?', ['intropage']))->toBe($current); + + $columns = array_column(intropage_e2e_rows('SHOW COLUMNS FROM plugin_intropage_user_auth'), 'Field'); + + sort($columns); + + expect($columns)->toBe(['login_opts', 'permissions', 'user_id']); + + $first = json_decode((string) intropage_e2e_cell('SELECT permissions FROM plugin_intropage_user_auth WHERE user_id = ?', [$viewer]), true); + $second = json_decode((string) intropage_e2e_cell('SELECT permissions FROM plugin_intropage_user_auth WHERE user_id = ?', [$other]), true); + + expect($first)->toHaveKey('alert_host', 'on'); + expect($first)->toHaveKey('favourite_graph', 'on'); + expect($first)->not->toHaveKey('retired_panel'); + expect($first)->not->toHaveKey('user_id'); + expect($second)->toHaveKey('favourite_graph', 'on'); + expect($second['alert_host'] ?? '')->toBe(''); + + foreach ([ + ['plugin_intropage_dashboard', 'shared'], + ['plugin_intropage_panel_data', 'height'], + ['plugin_intropage_panel_data', 'trend_interval'], + ['plugin_intropage_panel_definition', 'height'], + ['plugin_intropage_panel_definition', 'trefresh'], + ] as [$table, $column]) { + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?', [$table, $column]))->toBe(1); + } + + expect((int) intropage_e2e_cell('SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?', ['plugin_intropage_user_group_auth']))->toBe(1); + + // The real plugin tree is untouched + expect(is_dir($plugin . '/tests'))->toBeTrue(); + + // Put the permissions column back the way a fresh install creates it + intropage_e2e_exec('ALTER TABLE plugin_intropage_user_auth MODIFY permissions BLOB NOT NULL DEFAULT \'\''); + intropage_e2e_exec('REPLACE INTO plugin_intropage_user_auth (user_id, permissions) VALUES (?, ?)', [$viewer, json_encode(['alert_host' => 'on'])]); + }); + + it('refuses to run against a tree that is not a throwaway copy', function () { + $config = intropage_e2e_config(); + $output = []; + $status = 0; + + exec('INTROPAGE_E2E_CACTI=' . escapeshellarg($config['cacti']) . ' INTROPAGE_E2E_UPGRADE_BASE=' . escapeshellarg($config['cacti']) . ' ' . escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(__DIR__ . '/upgrade-runner.php') . ' 2>&1', $output, $status); + + expect($status)->toBe(1); + expect(implode("\n", $output))->toContain('refusing to run'); + }); + +}); diff --git a/tests/Integration/coverage-prepend.php b/tests/Integration/coverage-prepend.php new file mode 100644 index 0000000..f137636 --- /dev/null +++ b/tests/Integration/coverage-prepend.php @@ -0,0 +1,101 @@ + $info) { + if (strpos($file, $marker) === false || strpos($file, $tests) !== false) { + continue; + } + + // Keep the lines and the first line and hit count of each branch; the path data is far larger + $keep[$file] = ['lines' => $info['lines'] ?? [], 'functions' => []]; + + foreach ($info['functions'] ?? [] as $function => $detail) { + foreach ($detail['branches'] ?? [] as $id => $branch) { + $keep[$file]['functions'][$function]['branches'][$id] = ['line_start' => $branch['line_start'] ?? 0, 'hit' => $branch['hit'] ?? 0]; + } + } + } + + if ($keep === []) { + return; + } + + $target = $intropage_cov_dir . '/' . $token; + + if (!is_dir($target)) { + @mkdir($target, 0775, true); + } + + @file_put_contents($target . '/' . getmypid() . '-' . bin2hex(random_bytes(6)) . '.json', json_encode($keep)); +}); diff --git a/tests/Integration/hook-runner.php b/tests/Integration/hook-runner.php new file mode 100644 index 0000000..0073185 --- /dev/null +++ b/tests/Integration/hook-runner.php @@ -0,0 +1,60 @@ + 'view', 'local_graph_id' => (int) $intropage_graph, 'rra' => 1, 'view_type' => '']); + + print "\n"; +} diff --git a/tests/Integration/upgrade-runner.php b/tests/Integration/upgrade-runner.php new file mode 100644 index 0000000..9521386 --- /dev/null +++ b/tests/Integration/upgrade-runner.php @@ -0,0 +1,62 @@ +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/Security/TestFilesInertTest.php b/tests/Security/TestFilesInertTest.php new file mode 100644 index 0000000..a423405 --- /dev/null +++ b/tests/Security/TestFilesInertTest.php @@ -0,0 +1,125 @@ +not->toBeFalse(); + + $output = []; + $status = 1; + + exec('INTROPAGE_E2E_HOST=127.0.0.1:9 INTROPAGE_E2E_DB_NAME=none ' . escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($file) . ' 2>&1', $output, $status); + + expect($output)->toBe([]); + expect($status)->toBe(0); +}); + +it('guards the end-to-end test file before it declares anything', function () { + $source = file_get_contents(__DIR__ . '/../Integration/IntropageHttpTest.php'); + + $guard = strpos($source, "function_exists('beforeAll')"); + $first = strpos($source, 'const INTROPAGE_E2E_PASS'); + + expect($guard)->not->toBeFalse(); + expect($guard)->toBeLessThan($first); + expect($source)->not->toContain('$_GET'); + expect($source)->not->toContain('$_POST'); + expect($source)->not->toContain('$_REQUEST'); + expect($source)->not->toContain('$_COOKIE'); + expect($source)->not->toContain('$_SERVER'); +}); + +it('does nothing when the coverage prepend file is executed directly', function () { + $file = realpath(__DIR__ . '/../Integration/coverage-prepend.php'); + $dir = sys_get_temp_dir() . '/intropage-inert-' . bin2hex(random_bytes(6)); + + mkdir($dir, 0700); + + $output = []; + $status = 1; + + exec('INTROPAGE_COVERAGE_DIR=' . escapeshellarg($dir) . ' ' . escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($file) . ' 2>&1', $output, $status); + + expect($output)->toBe([]); + expect($status)->toBe(0); + expect(scandir($dir))->toBe(['.', '..']); +}); + +/** + * Writes a script that loads one plugin file and prints a marker. + */ +function intropage_inert_script(string $dir, string $plugin): string { + $script = $dir . '/run.php'; + + file_put_contents($script, '&1', $output); + + expect($output)->toBe(['ran']); + expect(scandir($dir))->toBe(['.', '..', 'run.php']); +}); + +it('keeps the coverage dump inside its directory whatever the token file says', function () { + if (!function_exists('xdebug_start_code_coverage')) { + $this->markTestSkipped('Xdebug is not loaded.'); + } + + $file = realpath(__DIR__ . '/../Integration/coverage-prepend.php'); + $root = sys_get_temp_dir() . '/intropage-inert-' . bin2hex(random_bytes(6)); + $dir = $root . '/dumps'; + + mkdir($dir, 0700, true); + file_put_contents($dir . '/current', '../escape'); + + $output = []; + + exec('INTROPAGE_COVERAGE_DIR=' . escapeshellarg($dir) . ' ' . escapeshellarg(PHP_BINARY) . ' -d xdebug.mode=coverage -d auto_prepend_file=' . escapeshellarg($file) . ' ' . escapeshellarg(intropage_inert_script($root, dirname($file, 3))) . ' 2>&1', $output); + + expect($output)->toBe(['ran']); + expect(is_dir($root . '/escape'))->toBeFalse(); + expect(count(glob($dir . '/untagged/*.json')))->toBe(1); +}); + +it('does nothing when the upgrade runner is executed without the test environment', function () { + $output = []; + $status = 1; + + exec('env -u INTROPAGE_E2E_CACTI -u INTROPAGE_E2E_UPGRADE_BASE ' . escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(realpath(__DIR__ . '/../Integration/upgrade-runner.php')) . ' 2>&1', $output, $status); + + expect($output)->toBe([]); + expect($status)->toBe(0); +}); + +it('does nothing when the hook runner is executed without the test environment', function () { + $output = []; + $status = 1; + + exec('env -u INTROPAGE_E2E_CACTI -u INTROPAGE_E2E_HOOK_USER -u INTROPAGE_E2E_HOOK_GRAPH ' . escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(realpath(__DIR__ . '/../Integration/hook-runner.php')) . ' 2>&1', $output, $status); + + expect($output)->toBe([]); + expect($status)->toBe(0); +}); diff --git a/tests/Unit/DeviceScopeTest.php b/tests/Unit/DeviceScopeTest.php index bb799ce..42f524b 100644 --- a/tests/Unit/DeviceScopeTest.php +++ b/tests/Unit/DeviceScopeTest.php @@ -43,3 +43,36 @@ expect($scope['allowed'])->toBeFalse(); }); }); + +describe('intropage_get_allowed_devices hide_disabled handling', function () { + beforeEach(function () { + $GLOBALS['__test_user_settings'] = []; + $GLOBALS['__test_user_setting'] = ['hide_disabled' => 'on']; + }); + + afterEach(function () { + unset($GLOBALS['__test_user_setting'], $GLOBALS['__test_allowed_devices_throw'], $GLOBALS['__test_allowed_devices']); + }); + + it('clears the preference for the lookup and puts it back', function () { + $GLOBALS['__test_allowed_devices'] = [['id' => 4]]; + + expect(intropage_get_allowed_devices(9))->toBe('4'); + expect($GLOBALS['__test_user_settings'])->toBe([['hide_disabled', ''], ['hide_disabled', 'on']]); + }); + + it('puts the preference back when the lookup throws', function () { + $GLOBALS['__test_allowed_devices_throw'] = true; + + expect(fn () => intropage_get_allowed_devices(9))->toThrow(RuntimeException::class); + expect($GLOBALS['__test_user_settings'])->toBe([['hide_disabled', ''], ['hide_disabled', 'on']]); + }); + + it('leaves the preference alone when it is not set', function () { + $GLOBALS['__test_user_setting'] = []; + + intropage_get_allowed_devices(9); + + expect($GLOBALS['__test_user_settings'])->toBe([]); + }); +}); diff --git a/tests/Unit/IntropageChartLabelsTest.php b/tests/Unit/IntropageChartLabelsTest.php new file mode 100644 index 0000000..58b7940 --- /dev/null +++ b/tests/Unit/IntropageChartLabelsTest.php @@ -0,0 +1,60 @@ +toBe('["Linux",12],'); + }); + + it('keeps quotes, brackets and script tags inside the string', function () { + $column = intropage_chart_column("a'\"]];&", 3); + + expect($column)->toBe('["a\u0027\u0022]];\u003C\/script\u003E\u0026",3],'); + expect(json_decode(rtrim($column, ','), true))->toBe(["a'\"]];&", 3]); + }); + + it('replaces a non-numeric value with zero', function () { + expect(intropage_chart_column('x', 'abc'))->toBe('["x",0],'); + }); + + it('keeps a numeric string as a number', function () { + expect(intropage_chart_column('x', '7'))->toBe('["x",7],'); + }); + + it('substitutes invalid UTF-8 instead of dropping the column', function () { + expect(intropage_chart_column("bad\xFF", 1))->toBe('["bad\ufffd",1],'); + }); +}); + +describe('intropage_prepare_graph label handling', function () { + foreach (['pie', 'treemap'] as $type) { + it("does not let a $type label end its string literal", function () use ($type) { + $label = "x',0]];marker();//"; + + $html = intropage_prepare_graph([ + $type => [ + 'title' => 'Templates', + 'label' => [$label], + 'data' => [4], + ], + ], 1); + + expect($html)->toContain('["x\u0027,0]];marker();\/\/",4],'); + expect($html)->not->toContain("['x',0]]"); + expect($html)->not->toContain("',0]];marker()"); + }); + } +}); diff --git a/tests/Unit/IntropageConsoleAfterTest.php b/tests/Unit/IntropageConsoleAfterTest.php new file mode 100644 index 0000000..cb4da6a --- /dev/null +++ b/tests/Unit/IntropageConsoleAfterTest.php @@ -0,0 +1,69 @@ +restore = $GLOBALS['config']; +}); + +afterEach(function () { + $GLOBALS['config'] = $this->restore; + + unset($GLOBALS['__test_query']); +}); + +it('renders nothing on an online remote poller without the viewer realm', function () { + $GLOBALS['config']['poller_id'] = 2; + $GLOBALS['config']['connection'] = 'online'; + + ob_start(); + intropage_console_after(); + $output = ob_get_clean(); + + expect($output)->toBe(''); +}); + +it('renders nothing on the main poller without the viewer realm', function () { + $GLOBALS['config']['poller_id'] = 1; + $GLOBALS['config']['connection'] = 'online'; + + ob_start(); + intropage_console_after(); + $output = ob_get_clean(); + + expect($output)->toBe(''); +}); + +it('renders nothing for a viewer on an offline remote poller', function () { + $GLOBALS['__test_realms']['intropage.php'] = true; + $GLOBALS['config']['poller_id'] = 2; + $GLOBALS['config']['connection'] = 'offline'; + + ob_start(); + intropage_console_after(); + $output = ob_get_clean(); + + expect($output)->toBe(''); +}); diff --git a/tests/Unit/IntropageDashboardNamesTest.php b/tests/Unit/IntropageDashboardNamesTest.php new file mode 100644 index 0000000..e3152b5 --- /dev/null +++ b/tests/Unit/IntropageDashboardNamesTest.php @@ -0,0 +1,57 @@ + 'Core routers']; + + intropage_action_settings(); + + $sql = implode("\n", array_column($GLOBALS['__test_db_calls'], 'sql')); + + expect($sql)->toContain('UPDATE plugin_intropage_dashboard'); + expect($sql)->not->toContain('REPLACE'); + expect($GLOBALS['__test_db_calls'][0]['params'])->toBe(['Core routers', 301, 2]); +}); + +it('skips names whose dashboard id is not a plain integer', function () { + $_POST = ['name_x' => 'bad', 'name_03' => 'bad', 'name_1e3' => 'bad']; + + intropage_action_settings(); + + expect($GLOBALS['__test_db_calls'])->toBe([]); +}); + +it('skips a name that was posted as an array', function () { + $_POST = ['name_2' => ['a', 'b']]; + + intropage_action_settings(); + + expect($GLOBALS['__test_db_calls'])->toBe([]); +}); 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/IntropagePanelAccessTest.php b/tests/Unit/IntropagePanelAccessTest.php new file mode 100644 index 0000000..aafdb08 --- /dev/null +++ b/tests/Unit/IntropagePanelAccessTest.php @@ -0,0 +1,215 @@ + 'on' map stored for the user + * @param array $rows panel data rows keyed by row id + */ +function intropage_access_fixture(array $permissions, array $rows): void { + $GLOBALS['__test_query'] = function ($kind, $sql, $params) use ($permissions, $rows) { + if (strpos($sql, 'FROM plugin_intropage_user_auth') !== false) { + return json_encode($permissions); + } + + if (strpos($sql, 'FROM plugin_intropage_panel_definition') !== false) { + $name = $params[0] ?? ''; + + return [ + 'panel_id' => $name, + 'name' => $name, + 'level' => 0, + 'priority' => 10, + 'alarm' => 'grey', + 'refresh' => 300, + 'trefresh' => 300, + 'height' => 'normal', + ]; + } + + if (strpos($sql, 'FROM plugin_intropage_panel_data') !== false) { + $row = $rows[$params[0]] ?? null; + + if ($row === null) { + return $kind === 'cell' ? '' : []; + } + + return $kind === 'cell' ? $row['panel_id'] : $row; + } + + return $kind === 'assoc' ? [] : ''; + }; +} + +function intropage_access_row(int $id, string $panel_id): array { + return [ + 'id' => $id, + 'panel_id' => $panel_id, + 'user_id' => 0, + 'ts' => time(), + 'refresh_interval' => 300, + 'trend_interval' => 300, + 'alarm' => 'grey', + 'height' => 'normal', + 'data' => '', + 'fav_graph_id' => null, + ]; +} + +function intropage_access_inserts(): array { + return array_values(array_filter($GLOBALS['__test_db_calls'], function ($call) { + return strpos($call['sql'], 'INSERT INTO plugin_intropage_panel_dashboard') !== false; + })); +} + +beforeEach(function () { + $GLOBALS['__test_db_calls'] = []; + $GLOBALS['__test_saves'] = []; + $GLOBALS['__test_request'] = []; + + $_SESSION['intropage_login_opts'] = 1; + $_SESSION['dashboard_id'] = 1; +}); + +afterEach(function () { + unset($GLOBALS['__test_query'], $GLOBALS['__test_request']); +}); + +describe('intropage_panel_visible', function () { + it('follows the stored panel permission', function () { + $_SESSION['sess_user_id'] = 101; + + intropage_access_fixture(['alert_host' => 'on'], []); + + expect(intropage_panel_visible('alert_host'))->toBeTrue(); + expect(intropage_panel_visible('analyse_db'))->toBeFalse(); + }); + + it('always shows the admin alert and maintenance panels', function () { + $_SESSION['sess_user_id'] = 102; + + intropage_access_fixture([], []); + + expect(intropage_panel_visible('admin_alert'))->toBeTrue(); + expect(intropage_panel_visible('maint'))->toBeTrue(); + }); + + it('hides a panel with no name', function () { + $_SESSION['sess_user_id'] = 103; + + intropage_access_fixture(['alert_host' => 'on'], []); + + expect(intropage_panel_visible(null))->toBeFalse(); + }); +}); + +describe('adding a panel', function () { + it('refuses a panel row the user has no permission for', function () { + $_SESSION['sess_user_id'] = 104; + + intropage_access_fixture(['alert_host' => 'on'], [7 => intropage_access_row(7, 'analyse_db')]); + $GLOBALS['__test_request'] = ['intropage_addpanel' => '7', 'dashboard_id' => '1']; + + intropage_action_add_panel(); + + expect(intropage_access_inserts())->toBe([]); + }); + + it('adds a panel row the user has permission for', function () { + $_SESSION['sess_user_id'] = 105; + + intropage_access_fixture(['alert_host' => 'on'], [8 => intropage_access_row(8, 'alert_host')]); + $GLOBALS['__test_request'] = ['intropage_addpanel' => '8', 'dashboard_id' => '1']; + + intropage_action_add_panel(); + + $inserts = intropage_access_inserts(); + + expect($inserts)->toHaveCount(1); + expect($inserts[0]['params'])->toBe(['8', 105, '1']); + }); + + it('refuses to create a panel by name without permission', function () { + $_SESSION['sess_user_id'] = 106; + + intropage_access_fixture(['alert_host' => 'on'], []); + $GLOBALS['__test_request'] = ['intropage_addpanel' => 'analyse_db', 'dashboard_id' => '1']; + + intropage_action_add_panel(); + + expect($GLOBALS['__test_saves'])->toBe([]); + expect(intropage_access_inserts())->toBe([]); + }); + + it('creates a panel by name with permission', function () { + $_SESSION['sess_user_id'] = 107; + + intropage_access_fixture(['alert_host' => 'on'], []); + $GLOBALS['__test_request'] = ['intropage_addpanel' => 'alert_host', 'dashboard_id' => '1']; + + intropage_action_add_panel(); + + expect($GLOBALS['__test_saves'])->toHaveCount(1); + expect($GLOBALS['__test_saves'][0]['row']['panel_id'])->toBe('alert_host'); + expect(intropage_access_inserts())->toHaveCount(1); + }); +}); + +describe('reload and detail endpoints', function () { + it('does not reload a panel the user has no permission for', function () { + $_SESSION['sess_user_id'] = 108; + + intropage_access_fixture(['alert_host' => 'on'], [9 => intropage_access_row(9, 'analyse_db')]); + $GLOBALS['__test_request'] = ['panel_id' => '9', 'force' => 'true']; + + ob_start(); + intropage_reload_panel(); + $output = ob_get_clean(); + + expect($output)->toBe('Panel not found'); + expect($GLOBALS['__test_db_calls'])->toBe([]); + }); + + it('does not show the details of a panel the user has no permission for', function () { + $_SESSION['sess_user_id'] = 109; + + intropage_access_fixture(['alert_host' => 'on'], [9 => intropage_access_row(9, 'analyse_db')]); + $GLOBALS['__test_request'] = ['panel_id' => '9']; + + ob_start(); + intropage_detail_panel(); + $output = ob_get_clean(); + + expect($output)->toBe('Panel Not Found'); + }); + + it('does not create a panel row when the details of an unknown id are requested', function () { + $_SESSION['sess_user_id'] = 110; + + intropage_access_fixture(['alert_host' => 'on'], []); + $GLOBALS['__test_request'] = ['panel_id' => '12345']; + + ob_start(); + intropage_detail_panel(); + $output = ob_get_clean(); + + expect($output)->toBe('Panel Not Found'); + expect($GLOBALS['__test_saves'])->toBe([]); + }); +}); diff --git a/tests/Unit/IntropagePanelHelpersTest.php b/tests/Unit/IntropagePanelHelpersTest.php index 26f2617..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 () { @@ -58,3 +61,10 @@ expect($GLOBALS['__test_db_calls'][0]['sql'])->toContain('plugin_intropage_user_group_auth'); expect($GLOBALS['__test_db_calls'][0]['params'])->toBe(array(7)); }); + +it('deletes the group auth record by its user_group_id key', function () { + intropage_user_group_remove(7); + + expect($GLOBALS['__test_db_calls'][0]['sql'])->toContain('WHERE user_group_id = ?'); + expect($GLOBALS['__test_db_calls'][0]['sql'])->not->toContain('WHERE id = ?'); +}); 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' => ['']]); +}); diff --git a/tests/Unit/IntropagePollerUserTest.php b/tests/Unit/IntropagePollerUserTest.php new file mode 100644 index 0000000..6d8ddfd --- /dev/null +++ b/tests/Unit/IntropagePollerUserTest.php @@ -0,0 +1,70 @@ +toBe(7); + expect($_SESSION['sess_user_id'])->toBe(7); + }); + + it('runs a system panel as user 0 instead of the previous row user', function () { + $_SESSION['sess_user_id'] = 7; + + expect(intropage_poller_impersonate(0))->toBe(0); + expect($_SESSION['sess_user_id'])->toBe(0); + }); + + it('maps the NULL user of a definition without a data row to user 0', function () { + $_SESSION['sess_user_id'] = 7; + + $user_id = intropage_poller_impersonate(null); + + expect($user_id)->toBe(0); + expect($_SESSION['sess_user_id'])->toBe(0); + }); + + it('lets get_panel create the missing system panel data row', function () { + $GLOBALS['__test_saves'] = []; + $GLOBALS['__test_query'] = function ($kind, $sql, $params) { + if (strpos($sql, 'FROM plugin_intropage_panel_definition') !== false) { + return [ + 'panel_id' => 'alert_host', + 'name' => 'Host alerts', + 'level' => 0, + 'priority' => 10, + 'alarm' => 'grey', + 'refresh' => 300, + 'trefresh' => 300, + 'height' => 'normal', + ]; + } + + return $kind === 'assoc' ? [] : (($kind === 'row') ? [] : ''); + }; + + $_SESSION['sess_user_id'] = 7; + + $panel = get_panel('alert_host', intropage_poller_impersonate(null)); + + unset($GLOBALS['__test_query']); + + expect($panel['panel_id'])->toBe('alert_host'); + expect($GLOBALS['__test_saves'])->toHaveCount(1); + expect($GLOBALS['__test_saves'][0]['row']['user_id'])->toBe(0); + }); +}); diff --git a/tests/Unit/IntropageProhibitedPanelsTest.php b/tests/Unit/IntropageProhibitedPanelsTest.php new file mode 100644 index 0000000..ead712a --- /dev/null +++ b/tests/Unit/IntropageProhibitedPanelsTest.php @@ -0,0 +1,80 @@ + 'on']); + } + + if (strpos($sql, 'INNER JOIN plugin_intropage_panel_dashboard') !== false) { + return [ + ['panel_name' => 'alert_host', 'id' => '5'], + ['panel_name' => 'analyse_db', 'id' => '6'], + ]; + } + + return $kind === 'assoc' ? [] : ''; + }; +}); + +afterEach(function () { + unset($GLOBALS['__test_query']); +}); + +it('removes only the panels the user may no longer see', function () { + expect(intropage_remove_prohibited_panels(501, 1))->toBe(1); + + $deletes = array_values(array_filter($GLOBALS['__test_db_calls'], function ($call) { + return strpos($call['sql'], 'DELETE FROM') !== false; + })); + + expect($deletes)->toHaveCount(2); + expect($deletes[0]['sql'])->toContain('plugin_intropage_panel_dashboard'); + expect($deletes[0]['params'])->toBe([501, 1, '6']); +}); + +it('deletes the user data row by its row id', function () { + intropage_remove_prohibited_panels(502, 1); + + $deletes = array_values(array_filter($GLOBALS['__test_db_calls'], function ($call) { + return strpos($call['sql'], 'DELETE FROM plugin_intropage_panel_data') !== false; + })); + + expect($deletes)->toHaveCount(1); + expect($deletes[0]['sql'])->toContain('AND id = ?'); + expect($deletes[0]['sql'])->not->toContain('AND panel_id = ?'); + expect($deletes[0]['params'])->toBe([502, '6']); +}); + +it('removes nothing when every panel is permitted', function () { + $GLOBALS['__test_query'] = function ($kind, $sql, $params) { + if (strpos($sql, 'FROM plugin_intropage_user_auth') !== false) { + return json_encode(['alert_host' => 'on']); + } + + if (strpos($sql, 'INNER JOIN plugin_intropage_panel_dashboard') !== false) { + return [['panel_name' => 'alert_host', 'id' => '5']]; + } + + return $kind === 'assoc' ? [] : ''; + }; + + expect(intropage_remove_prohibited_panels(503, 1))->toBe(0); + expect($GLOBALS['__test_db_calls'])->toBe([]); +}); diff --git a/tests/Unit/IntropageRequestMethodTest.php b/tests/Unit/IntropageRequestMethodTest.php new file mode 100644 index 0000000..60200a9 --- /dev/null +++ b/tests/Unit/IntropageRequestMethodTest.php @@ -0,0 +1,203 @@ +toBeTrue(); + + $_SERVER['REQUEST_METHOD'] = 'GET'; + expect(intropage_is_post_request())->toBeFalse(); + + unset($_SERVER['REQUEST_METHOD']); + expect(intropage_is_post_request())->toBeFalse(); + }); +}); + +describe('process_page_request_variables', function () { + it('ignores a dashboard action sent as GET', function () { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $GLOBALS['__test_request'] = ['intropage_action' => 'share']; + + process_page_request_variables(); + + expect(intropage_method_writes())->toBe([]); + }); + + it('runs the same action sent as POST', function () { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $GLOBALS['__test_request'] = ['intropage_action' => 'share']; + + process_page_request_variables(); + + $writes = intropage_method_writes(); + + expect($writes)->toHaveCount(1); + expect($writes[0]['sql'])->toContain('SET shared = 1'); + }); + + it('ignores adding a panel sent as GET', function () { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $GLOBALS['__test_request'] = ['intropage_addpanel' => '3', 'dashboard_id' => '1']; + + process_page_request_variables(); + + expect(intropage_method_writes())->toBe([]); + }); + + it('adds a panel sent as POST', function () { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $GLOBALS['__test_request'] = ['intropage_addpanel' => '3', 'dashboard_id' => '1']; + $GLOBALS['__test_query'] = function ($kind, $sql, $params) { + if (strpos($sql, 'FROM plugin_intropage_user_auth') !== false) { + return json_encode(['alert_host' => 'on']); + } + + if (strpos($sql, 'SELECT panel_id') !== false) { + return 'alert_host'; + } + + return $kind === 'assoc' ? [] : ''; + }; + + process_page_request_variables(); + + $writes = intropage_method_writes(); + + expect($writes)->toHaveCount(1); + expect($writes[0]['sql'])->toContain('INSERT INTO plugin_intropage_panel_dashboard'); + }); + + it('saves settings sent as POST', function () { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_POST = ['name_2' => 'Edge']; + $GLOBALS['__test_request'] = ['action' => 'save', 'save_settings' => '1']; + + process_page_request_variables(); + + $_POST = []; + + $writes = intropage_method_writes(); + + expect($writes)->toHaveCount(1); + expect($writes[0]['params'])->toBe(['Edge', 201, 2]); + }); + + it('ignores saving settings sent as GET', function () { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $GLOBALS['__test_request'] = ['action' => 'save', 'save_settings' => '1']; + + process_page_request_variables(); + + expect(intropage_method_writes())->toBe([]); + }); + + it('ignores a timespan change sent as GET', function () { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $GLOBALS['__test_request'] = ['intropage_action_timespan' => 'timespan_3600']; + + process_page_request_variables(); + + expect($GLOBALS['__test_user_settings'])->toBe([]); + }); + + it('applies a timespan change sent as POST', function () { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $GLOBALS['__test_request'] = ['intropage_action_timespan' => 'timespan_3600']; + + process_page_request_variables(); + + expect($GLOBALS['__test_user_settings'])->toBe([['intropage_timespan', '3600']]); + }); +}); + +describe('get_login_opts', function () { + it('does not switch the login option for a GET request', function () { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $GLOBALS['__test_request'] = ['intropage_action' => 'loginopt_tab']; + + expect(get_login_opts())->toBe(1); + }); + + it('switches the login option for a POST request', function () { + $_SERVER['REQUEST_METHOD'] = 'POST'; + + foreach (['loginopt_tab' => 4, 'loginopt_graph' => 2, 'loginopt_console' => 1] as $action => $expected) { + $GLOBALS['__test_request'] = ['intropage_action' => $action]; + $_SESSION['intropage_login_opts'] = 9; + + expect(get_login_opts())->toBe($expected); + } + }); +}); + +describe('intropage_graph_button', function () { + it('renders a non-navigating button and binds one POST handler per page', function () { + global $redirectPage; + + // is_panel_allowed() caches per user, so this test uses its own user + $_SESSION['sess_user_id'] = 202; + $_SESSION['sess_current_timespan'] = 5; + $GLOBALS['__test_query'] = function ($kind, $sql, $params) { + if (strpos($sql, 'FROM plugin_intropage_user_auth') !== false) { + return json_encode(['favourite_graph' => 'on']); + } + + return $kind === 'assoc' ? [] : '0'; + }; + + ob_start(); + intropage_graph_button([1 => ['local_graph_id' => 42]]); + $first = ob_get_clean(); + + ob_start(); + intropage_graph_button([1 => ['local_graph_id' => 43]]); + $second = ob_get_clean(); + + expect($first)->toContain('data-graph="42"'); + expect($first)->toContain('class="iconLink intropage_favgraph"'); + expect($first)->not->toContain('intropage_action=favgraph'); + expect($first)->toContain('intropage_action: "favgraph"'); + expect($first)->toContain('__csrf_magic: csrfMagicToken'); + expect($first)->toContain('method=\"post\"'); + expect(substr_count($first, 'toBe(1); + + expect($second)->toContain('data-graph="43"'); + expect($second)->not->toContain(' true]; + $GLOBALS['__test_simple_perms'] = true; + $GLOBALS['__test_events'] = []; + + $GLOBALS['__test_query'] = function ($kind, $sql, $params) { + if (strpos($sql, 'FROM plugin_thold_log') !== false) { + $GLOBALS['__test_events'][] = ['sql' => $sql, 'params' => $params]; + + return [ + ['description' => 'Threshold ALERT', 'time' => time(), 'status' => 1, 'user0' => null, 'user1' => null, 'user2' => null], + ]; + } + + return $kind === 'assoc' ? [] : ''; + }; + + $this->restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = sys_get_temp_dir() . '/intropage-thold-events-' . uniqid(); +}); + +afterEach(function () { + $GLOBALS['config']['base_path'] = $this->restore; + + unset($GLOBALS['__test_query'], $GLOBALS['__test_enabled_plugins'], $GLOBALS['__test_simple_perms']); +}); + +it('limits the permission joins of the event panel to the panel user', function () { + $_SESSION['sess_user_id'] = 601; + + thold_event(['id' => 1, 'height' => 'normal', 'data' => '', 'alarm' => 'green'], 601); + + expect($GLOBALS['__test_events'])->toHaveCount(1); + + $sql = $GLOBALS['__test_events'][0]['sql']; + + expect($sql)->toContain('uap0.type=1 AND uap0.user_id = ?'); + expect($sql)->toContain('uap1.type=3 AND uap1.user_id = ?'); + expect($sql)->toContain('uap2.type=4 AND uap2.user_id = ?'); + expect($GLOBALS['__test_events'][0]['params'])->toBe([601, 601, 601]); +}); + +it('limits the permission joins of the event detail to the session user', function () { + $_SESSION['sess_user_id'] = 602; + + $panel = thold_event_detail(); + + expect($GLOBALS['__test_events'])->toHaveCount(1); + expect($GLOBALS['__test_events'][0]['sql'])->toContain('uap2.type=4 AND uap2.user_id = ?'); + expect($GLOBALS['__test_events'][0]['params'])->toBe([602, 602, 602]); + expect($panel['detail'])->toContain('Threshold <b>ALERT</b>'); +}); diff --git a/tests/Unit/IntropageUpgradeTest.php b/tests/Unit/IntropageUpgradeTest.php new file mode 100644 index 0000000..78a2a96 --- /dev/null +++ b/tests/Unit/IntropageUpgradeTest.php @@ -0,0 +1,91 @@ + [], 'expected' => [], 'whitelist' => []])); + symlink($real . '/includes', $plugin . '/includes'); + symlink($real . '/panellib', $plugin . '/panellib'); + + return $base; +} + +function intropage_upgrade_user_auth_updates(): array { + return array_values(array_filter($GLOBALS['__test_db_calls'], function ($call) { + return strpos($call['sql'], 'UPDATE plugin_intropage_user_auth') !== false + && strpos($call['sql'], 'SET permissions') !== false; + })); +} + +it('converts legacy per-panel columns to permissions without failing on the extra grant', function () { + $base = intropage_upgrade_fixture(); + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['__test_db_calls'] = []; + $GLOBALS['__test_query'] = function ($kind, $sql, $params) { + if (strpos($sql, 'SELECT version FROM plugin_config') !== false) { + return '3.0.0'; + } + + if (strpos($sql, 'SELECT * FROM plugin_intropage_user_auth') !== false) { + return [ + ['user_id' => '5', 'login_opts' => '0', 'alert_host' => 'on', 'retired_panel' => 'on'], + ['user_id' => '6', 'login_opts' => '0', 'alert_host' => '', 'retired_panel' => 'on'], + ]; + } + + if (strpos($sql, 'SHOW COLUMNS FROM plugin_intropage_user_auth') !== false) { + return [['Field' => 'user_id'], ['Field' => 'login_opts'], ['Field' => 'permissions']]; + } + + return $kind === 'assoc' ? [] : ''; + }; + + $GLOBALS['config']['base_path'] = $base; + + try { + intropage_upgrade_database(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + unset($GLOBALS['__test_query']); + } + + $updates = intropage_upgrade_user_auth_updates(); + + expect($updates)->toHaveCount(2); + + $first = json_decode($updates[0]['params'][0], true); + + expect($updates[0]['params'][1])->toBe('5'); + expect($first)->toHaveKey('favourite_graph', 'on'); + expect($first)->not->toHaveKey('retired_panel'); + expect($first)->not->toHaveKey('user_id'); + expect($first)->not->toHaveKey('login_opts'); + + $second = json_decode($updates[1]['params'][0], true); + + expect($updates[1]['params'][1])->toBe('6'); + expect($second)->toHaveKey('favourite_graph', 'on'); +}); diff --git a/tests/bin/e2e-coverage-report.php b/tests/bin/e2e-coverage-report.php new file mode 100644 index 0000000..6134fff --- /dev/null +++ b/tests/bin/e2e-coverage-report.php @@ -0,0 +1,244 @@ + [] [--lines] + * + * It only reads files and runs git; run it from the plugin directory. + */ + +if (PHP_SAPI !== 'cli') { + http_response_code(404); + + exit; +} + +if ($argc < 3) { + fwrite(STDERR, "usage: e2e-coverage-report.php [] [--lines]\n"); + + exit(2); +} + +$plugin = dirname(__DIR__, 2); +$base = $argv[1]; +$e2e_dir = rtrim($argv[2], '/'); +$unit_dir = isset($argv[3]) && $argv[3] !== '--lines' ? rtrim($argv[3], '/') : ''; +$per_line = in_array('--lines', $argv, true); + +/** + * Added or modified line numbers per non-test PHP file. + * + * @return array + */ +function report_changed_lines(string $base): array { + $diff = shell_exec('git diff --no-ext-diff --no-color -U0 --diff-filter=AM ' . escapeshellarg($base . '...HEAD') . ' -- ' . escapeshellarg('*.php') . ' ' . escapeshellarg(':!tests')); + $out = []; + + foreach (preg_split('/^diff --git /m', (string) $diff, -1, PREG_SPLIT_NO_EMPTY) as $chunk) { + if (!preg_match('#^a/(\S+) b/(\S+)#', $chunk, $name)) { + continue; + } + + preg_match_all('/^@@ -\S+ \+(\d+)(?:,(\d+))? @@/m', $chunk, $hunks, PREG_SET_ORDER); + + foreach ($hunks as $hunk) { + $count = isset($hunk[2]) && $hunk[2] !== '' ? (int) $hunk[2] : 1; + + for ($i = 0; $i < $count; $i++) { + $out[$name[2]][] = (int) $hunk[1] + $i; + } + } + } + + return $out; +} + +/** + * Merges the end-to-end dumps. + * + * @return array{lines: array>>, branches: array>>} + * lines[file][line][test] = hits; branches[file][function][branch id] = [first line, hits] + */ +function report_load_e2e(string $dir): array { + $lines = []; + $branches = []; + + foreach (glob($dir . '/*', GLOB_ONLYDIR) ?: [] as $test_dir) { + $test = basename($test_dir); + + foreach (glob($test_dir . '/*.json') ?: [] as $dump) { + $data = json_decode((string) file_get_contents($dump), true); + + if (!is_array($data)) { + continue; + } + + foreach ($data as $path => $info) { + $pos = strpos($path, '/plugins/intropage/'); + $rel = $pos === false ? $path : substr($path, $pos + strlen('/plugins/intropage/')); + + foreach ($info['lines'] ?? [] as $number => $hit) { + $lines[$rel][(int) $number][$test] = ($lines[$rel][(int) $number][$test] ?? 0) + max(0, (int) $hit); + } + + foreach ($info['functions'] ?? [] as $function => $detail) { + foreach ($detail['branches'] ?? [] as $id => $branch) { + $key = (int) $id; + $old = $branches[$rel][$function][$key] ?? [(int) ($branch['line_start'] ?? 0), 0]; + + $branches[$rel][$function][$key] = [$old[0], $old[1] + (int) ($branch['hit'] ?? 0)]; + } + } + } + } + } + + return ['lines' => $lines, 'branches' => $branches]; +} + +/** + * Covering unit tests per line from the PHPUnit XML report. + * + * @return array> + */ +function report_load_unit(string $dir): array { + $out = []; + + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)) as $file) { + if ($file->getExtension() !== 'xml' || $file->getFilename() === 'index.xml') { + continue; + } + + $xml = simplexml_load_file($file->getPathname()); + + if ($xml === false || !isset($xml->file['path'])) { + continue; + } + + // name is the file, path its directory relative to the source root + $sub = trim((string) $xml->file['path'], '/'); + $rel = ($sub === '' ? '' : $sub . '/') . (string) $xml->file['name']; + + foreach ($xml->file->coverage->line ?? [] as $line) { + foreach ($line->covered as $covered) { + $name = preg_replace('/^.*\\\\([^\\\\]+)::__pest_evaluable_/', '$1::', (string) $covered['by']); + $out[$rel][(int) $line['nr']][] = $name; + } + } + } + + return $out; +} + +/** + * Compact, stable list of test names for a table cell. + * + * @param string[] $names + */ +function report_names(array $names, int $limit = 3): string { + $names = array_values(array_unique($names)); + sort($names); + + if ($names === []) { + return '-'; + } + + $shown = array_slice($names, 0, $limit); + + return implode('; ', $shown) . (count($names) > $limit ? ' (+' . (count($names) - $limit) . ')' : ''); +} + +$changed = report_changed_lines($base); +$e2e = report_load_e2e($e2e_dir); +$unit = $unit_dir !== '' ? report_load_unit($unit_dir) : []; + +$uncovered = []; + +print "| File | Changed executable lines | Unit | End-to-end | Neither |\n|---|---|---|---|---|\n"; + +$detail = ''; + +foreach ($changed as $file => $lines) { + $source = file($plugin . '/' . $file, FILE_IGNORE_NEW_LINES) ?: []; + + // Only lines Xdebug lists as executable count. A bare closing brace is listed for the implicit + // return of a function and is not code of its own, so it is left out. + $executable = array_values(array_filter(array_unique($lines), static function (int $line) use ($file, $e2e, $source): bool { + return isset($e2e['lines'][$file][$line]) && trim($source[$line - 1] ?? '') !== '}'; + })); + + sort($executable); + + $in_unit = 0; + $in_e2e = 0; + $neither = []; + + foreach ($executable as $line) { + $by_unit = $unit[$file][$line] ?? []; + $by_e2e = array_keys(array_filter($e2e['lines'][$file][$line], static function (int $hits): bool { + return $hits > 0; + })); + + $in_unit += $by_unit !== [] ? 1 : 0; + $in_e2e += $by_e2e !== [] ? 1 : 0; + + if ($by_unit === [] && $by_e2e === []) { + $neither[] = $line; + $uncovered[] = $file . ':' . $line; + } + + if ($per_line) { + $detail .= sprintf("| %s:%d | %s | %s |\n", $file, $line, report_names($by_unit), report_names($by_e2e)); + } + } + + printf("| %s | %d | %d | %d | %s |\n", $file, count($executable), $in_unit, $in_e2e, $neither === [] ? '0' : count($neither) . ' (' . implode(', ', $neither) . ')'); +} + +if ($per_line) { + print "\n| Line | Unit tests | End-to-end tests |\n|---|---|---|\n" . $detail; +} + +print "\nBranches of changed lines that no end-to-end request reached:\n\n"; + +$none = true; + +foreach ($changed as $file => $lines) { + $lookup = array_flip($lines); + + foreach ($e2e['branches'][$file] ?? [] as $function => $branches) { + foreach ($branches as $id => [$line, $hits]) { + if ($hits === 0 && isset($lookup[$line])) { + $text = trim((string) (file($plugin . '/' . $file, FILE_IGNORE_NEW_LINES)[$line - 1] ?? '')); + + printf("- %s:%d in %s (branch %d): %s\n", $file, $line, $function, $id, substr($text, 0, 90)); + + $none = false; + } + } + } +} + +if ($none) { + print "- none\n"; +} + +exit($uncovered === [] ? 0 : 1); diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index 1bd63cb..ec27d6f 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -76,6 +76,21 @@ $GLOBALS['__test_db_calls'] = array(); +/* + * Tests may script query results by setting $GLOBALS['__test_query'] to a + * callable(string $kind, string $sql, array $params) that returns the rows, + * row or cell for that call. Unscripted calls keep the defaults below. + */ +if (!function_exists('intropage_test_query')) { + function intropage_test_query($kind, $sql, $params, $default) { + if (isset($GLOBALS['__test_query'])) { + return $GLOBALS['__test_query']($kind, $sql, $params); + } + + return $default; + } +} + if (!function_exists('get_md5_include_js')) { function get_md5_include_js($path, $async = false) { return ""; @@ -103,32 +118,32 @@ function db_execute_prepared($sql, $params = array()) { if (!function_exists('db_fetch_assoc')) { function db_fetch_assoc($sql) { $GLOBALS['__test_db_calls'][] = array('fn' => 'db_fetch_assoc', 'sql' => $sql, 'params' => array()); - return $GLOBALS['__test_fetch_assoc'] ?? array(); + return intropage_test_query('assoc', $sql, array(), $GLOBALS['__test_fetch_assoc'] ?? array()); } } if (!function_exists('db_fetch_assoc_prepared')) { function db_fetch_assoc_prepared($sql, $p = array()) { - return array(); + return intropage_test_query('assoc', $sql, $p, array()); } } if (!function_exists('db_fetch_row')) { function db_fetch_row($sql) { - return $GLOBALS['__test_fetch_row'] ?? array(); + return intropage_test_query('row', $sql, array(), $GLOBALS['__test_fetch_row'] ?? array()); } } if (!function_exists('db_fetch_row_prepared')) { function db_fetch_row_prepared($sql, $p = array()) { - return array(); + return intropage_test_query('row', $sql, $p, array()); } } if (!function_exists('db_fetch_cell')) { function db_fetch_cell($sql) { - return $GLOBALS['__test_fetch_cell'] ?? ''; + return intropage_test_query('cell', $sql, array(), $GLOBALS['__test_fetch_cell'] ?? ''); } } if (!function_exists('db_fetch_cell_prepared')) { function db_fetch_cell_prepared($sql, $p = array()) { - return ''; + return intropage_test_query('cell', $sql, $p, ''); } } if (!function_exists('db_index_exists')) { @@ -271,6 +286,10 @@ function get_simple_device_perms($user_id) { } if (!function_exists('get_allowed_devices')) { function get_allowed_devices($sql_where, $sql_order, $sql_limit, &$total_rows, $user_id = 0, ...$rest) { + if (!empty($GLOBALS['__test_allowed_devices_throw'])) { + throw new RuntimeException('device lookup failed'); + } + $rows = $GLOBALS['__test_allowed_devices'] ?? array(); $total_rows = count($rows); @@ -279,11 +298,12 @@ function get_allowed_devices($sql_where, $sql_order, $sql_limit, &$total_rows, $ } if (!function_exists('read_user_setting')) { function read_user_setting($n, $f = false, $force = false, $user_id = 0) { - return $f; + return $GLOBALS['__test_user_setting'][$n] ?? $f; } } if (!function_exists('set_user_setting')) { function set_user_setting($n, $v, $user_id = 0) { + $GLOBALS['__test_user_settings'][] = array($n, $v); } } if (!function_exists('raise_message')) { @@ -292,17 +312,41 @@ function raise_message($i, $t = '', $l = 0) { } if (!function_exists('get_request_var')) { function get_request_var($n) { - return ''; + return $GLOBALS['__test_request'][$n] ?? ''; } } if (!function_exists('get_nfilter_request_var')) { function get_nfilter_request_var($n) { - return ''; + return $GLOBALS['__test_request'][$n] ?? ''; } } if (!function_exists('get_filter_request_var')) { function get_filter_request_var($n) { - return ''; + return $GLOBALS['__test_request'][$n] ?? ''; + } +} +if (!function_exists('isset_request_var')) { + function isset_request_var($n) { + return isset($GLOBALS['__test_request'][$n]); + } +} +if (!function_exists('set_default_action')) { + function set_default_action() { + } +} +if (!function_exists('api_user_realm_auth')) { + function api_user_realm_auth($r) { + return $GLOBALS['__test_realms'][$r] ?? false; + } +} +if (!function_exists('api_plugin_user_realm_auth')) { + function api_plugin_user_realm_auth($r) { + return $GLOBALS['__test_realms'][$r] ?? false; + } +} +if (!function_exists('db_fetch_insert_id')) { + function db_fetch_insert_id() { + return $GLOBALS['__test_insert_id'] ?? 1; } } if (!function_exists('form_input_validate')) { @@ -317,6 +361,8 @@ function is_error_message() { } if (!function_exists('sql_save')) { function sql_save($a, $t, $k = 'id') { + $GLOBALS['__test_saves'][] = array('table' => $t, 'row' => $a); + return isset($a['id']) ? $a['id'] : 1; } }