diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml
new file mode 100644
index 0000000..779e8a4
--- /dev/null
+++ b/.github/workflows/plugin-ci-workflow.yml
@@ -0,0 +1,261 @@
+# +-------------------------------------------------------------------------+
+# | Copyright (C) 2004-2026 The Cacti Group |
+# | |
+# | This program is free software; you can redistribute it and/or |
+# | modify it under the terms of the GNU General Public License |
+# | as published by the Free Software Foundation; either version 2 |
+# | of the License, or (at your option) any later version. |
+# +-------------------------------------------------------------------------+
+
+name: Plugin Integration Tests
+
+on:
+ push:
+ branches:
+ - main
+ - develop
+ pull_request:
+ branches:
+ - main
+ - develop
+
+env:
+ CACTI: 1.2.x
+
+jobs:
+ integration-test:
+ runs-on: ${{ matrix.os }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['8.2', '8.3', '8.4']
+ os: [ubuntu-latest]
+
+ services:
+ mariadb:
+ image: mariadb:10.6
+ env:
+ MYSQL_ROOT_PASSWORD: cactiroot
+ MYSQL_DATABASE: cacti
+ MYSQL_USER: cactiuser
+ MYSQL_PASSWORD: cactiuser
+ ports:
+ - 3306:3306
+ options: >-
+ --health-cmd="mysqladmin ping"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=3
+
+ name: PHP ${{ matrix.php }} Integration Test on ${{ matrix.os }}
+
+ steps:
+ - name: Checkout Cacti
+ uses: actions/checkout@v4
+ with:
+ repository: Cacti/cacti
+ ref: ${{ env.CACTI }}
+ path: cacti
+
+ - name: Checkout flowview Plugin
+ uses: actions/checkout@v4
+ with:
+ path: cacti/plugins/flowview
+
+ - name: Install PHP ${{ matrix.php }}
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: intl, mysql, gd, ldap, gmp, xml, curl, json, mbstring, snmp
+ ini-values: "post_max_size=256M, max_execution_time=60, date.timezone=America/New_York"
+
+ - name: Check PHP version
+ run: php -v
+
+ - name: Run apt-get update
+ run: sudo apt-get update
+
+ - name: Install System Dependencies
+ run: sudo apt-get install -y snmp snmpd rrdtool fping
+
+ - name: Start SNMPD Agent and Test
+ run: |
+ sudo systemctl start snmpd
+ sudo snmpwalk -c public -v2c -On localhost .1.3.6.1.2.1.1
+
+ - name: Setup Permissions
+ run: |
+ sudo chown -R www-data:runner ${{ github.workspace }}/cacti
+ sudo find ${{ github.workspace }}/cacti -type d -exec chmod 775 {} \;
+ sudo find ${{ github.workspace }}/cacti -type f -exec chmod 664 {} \;
+ sudo chmod +x ${{ github.workspace }}/cacti/cmd.php
+ sudo chmod +x ${{ github.workspace }}/cacti/poller.php
+
+ - name: Create MySQL Config
+ run: |
+ echo -e "[client]\nuser = root\npassword = cactiroot\nhost = 127.0.0.1\n" > ~/.my.cnf
+ cat ~/.my.cnf
+
+ - name: Initialize Cacti Database
+ env:
+ MYSQL_AUTH_USR: '--defaults-file=~/.my.cnf'
+ run: |
+ mysql $MYSQL_AUTH_USR -e 'CREATE DATABASE IF NOT EXISTS cacti;'
+ mysql $MYSQL_AUTH_USR -e "CREATE USER IF NOT EXISTS 'cactiuser'@'localhost' IDENTIFIED BY 'cactiuser';"
+ mysql $MYSQL_AUTH_USR -e "GRANT ALL PRIVILEGES ON cacti.* TO 'cactiuser'@'localhost';"
+ mysql $MYSQL_AUTH_USR -e "GRANT SELECT ON mysql.time_zone_name TO 'cactiuser'@'localhost';"
+ mysql $MYSQL_AUTH_USR -e "FLUSH PRIVILEGES;"
+ mysql $MYSQL_AUTH_USR cacti < ${{ github.workspace }}/cacti/cacti.sql
+ mysql $MYSQL_AUTH_USR -e "INSERT INTO settings (name, value) VALUES ('path_php_binary', '/usr/bin/php')" cacti
+
+ - name: Validate composer files
+ run: |
+ cd ${{ github.workspace }}/cacti
+ if [ -f composer.json ]; then
+ composer validate --strict || true
+ fi
+
+ - name: Install Composer Dependencies
+ run: |
+ cd ${{ github.workspace }}/cacti
+ if [ -f composer.json ]; then
+ composer config --no-plugins allow-plugins.pestphp/pest-plugin true
+ composer require --dev --no-progress --no-interaction "pestphp/pest: ^3" "pestphp/pest-plugin-drift: ^3.0"
+ rm -f composer.lock
+ composer install --dev --no-progress
+ fi
+
+ - name: Create Cacti config.php
+ run: |
+ cat ${{ github.workspace }}/cacti/include/config.php.dist | \
+ sed -r "s/localhost/127.0.0.1/g" | \
+ sed -r "s/'cacti'/'cacti'/g" | \
+ sed -r "s/'cactiuser'/'cactiuser'/g" | \
+ sed -r "s/'cactiuser'/'cactiuser'/g" > ${{ github.workspace }}/cacti/include/config.php
+ sudo chmod 664 ${{ github.workspace }}/cacti/include/config.php
+
+ - name: Configure Apache
+ run: |
+ cat << 'EOF' | sed 's#GITHUB_WORKSPACE#${{ github.workspace }}#g' > /tmp/cacti.conf
+
+ ServerAdmin webmaster@localhost
+ DocumentRoot GITHUB_WORKSPACE/cacti
+
+
+ Options Indexes FollowSymLinks
+ AllowOverride All
+ Require all granted
+
+
+ ErrorLog ${APACHE_LOG_DIR}/error.log
+ CustomLog ${APACHE_LOG_DIR}/access.log combined
+
+ EOF
+ sudo cp /tmp/cacti.conf /etc/apache2/sites-available/000-default.conf
+ sudo systemctl restart apache2
+
+ - name: Install Cacti via CLI
+ run: |
+ cd ${{ github.workspace }}/cacti
+ sudo php cli/install_cacti.php --accept-eula --install --force
+
+ - name: Install flowview Plugin
+ run: |
+ cd ${{ github.workspace }}/cacti
+ sudo php cli/plugin_manage.php --plugin=flowview --install --enable
+
+ - name: Check PHP Syntax for Plugin
+ run: |
+ cd ${{ github.workspace }}/cacti/plugins/flowview
+ if find . -name '*.php' -exec php -l {} 2>&1 \; | grep -iv 'no syntax errors detected'; then
+ echo "Syntax errors found!"
+ exit 1
+ fi
+
+ - name: Set expected Cacti version for unit tests
+ run: echo -n "${{ env.CACTI }}" | sudo tee ${{ github.workspace }}/cacti/plugins/flowview/tests/.cacti-version > /dev/null
+
+ - name: Run Pest Unit Tests
+ env:
+ COMPOSER_ROOT_VERSION: 1.3.0-dev
+ run: |
+ cd ${{ github.workspace }}/cacti
+ include/vendor/bin/pest --configuration=plugins/flowview/phpunit.xml \
+ --coverage-clover=plugins/flowview/coverage/clover.xml \
+ plugins/flowview/tests/Security plugins/flowview/tests/Unit
+
+ - name: Upload coverage report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage-php${{ matrix.php }}
+ path: ${{ github.workspace }}/cacti/plugins/flowview/coverage/
+ if-no-files-found: warn
+
+ - name: Remove the plugins directory exclusion from the .phpstan.neon
+ if: ${{ env.CACTI != '1.2.x' }}
+ run: sed '/plugins/d' -i .phpstan.neon
+ working-directory: ${{ github.workspace }}/cacti
+
+ - name: Mark composer scripts executable
+ if: ${{ env.CACTI != '1.2.x' }}
+ run: sudo chmod +x ${{ github.workspace }}/cacti/include/vendor/bin/*
+ working-directory: ${{ github.workspace }}/cacti
+
+ - name: Run Linter on base code
+ if: ${{ env.CACTI != '1.2.x' }}
+ run: composer run-script lint ${{ github.workspace }}/cacti/plugins/flowview
+ working-directory: ${{ github.workspace }}/cacti
+
+ - name: Checking coding standards on base code
+ if: ${{ env.CACTI != '1.2.x' }}
+ run: composer run-script phpcsfixer ${{ github.workspace }}/cacti/plugins/flowview
+ working-directory: ${{ github.workspace }}/cacti
+
+ - name: Run PHPStan at Level 6 on base code outside of Composer due to technical issues
+ if: ${{ env.CACTI != '1.2.x' }}
+ run: ./include/vendor/bin/phpstan analyze --level 6 ${{ github.workspace }}/cacti/plugins/flowview
+ working-directory: ${{ github.workspace }}/cacti
+
+ - name: Re-apply web user ownership before polling
+ run: |
+ p="${{ github.workspace }}"
+ while [ "$p" != "/" ]; do
+ sudo chmod o+x "$p" || true
+ p=$(dirname "$p")
+ done
+ sudo chown -R www-data:runner ${{ github.workspace }}/cacti
+ sudo find ${{ github.workspace }}/cacti -type d -exec chmod 775 {} \;
+ sudo find ${{ github.workspace }}/cacti -type f -exec chmod 664 {} \;
+ sudo chmod +x ${{ github.workspace }}/cacti/cmd.php
+ sudo chmod +x ${{ github.workspace }}/cacti/poller.php
+
+ - name: Run Cacti Poller
+ run: |
+ cd ${{ github.workspace }}/cacti
+ sudo -u www-data php poller.php --poller=1 --force --debug
+
+ if ! grep -q "SYSTEM STATS" log/cacti.log; then
+ echo "Cacti poller did not finish successfully"
+ cat log/cacti.log
+ exit 1
+ fi
+
+ - name: View Cacti Logs
+ if: always()
+ run: |
+ if [ -f ${{ github.workspace }}/cacti/log/cacti.log ]; then
+ echo "=== Cacti Log ==="
+ sudo cat ${{ github.workspace }}/cacti/log/cacti.log
+ fi
+
+ - name: Check Cacti Logs for Errors
+ if: always()
+ run: |
+ if [ -f ${{ github.workspace }}/cacti/log/cacti.log ]; then
+ if grep -E "FATAL:|ERROR:|WARNING:" ${{ github.workspace }}/cacti/log/cacti.log; then
+ echo "Found FATAL/ERROR/WARNING entries in Cacti log"
+ exit 1
+ fi
+ fi
diff --git a/.gitignore b/.gitignore
index 5a322e8..2c3d780 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,6 +20,12 @@
# +-------------------------------------------------------------------------+
.git*
+!.github/
+!.github/workflows/
+!.github/workflows/*.yml
+.phpunit.result.cache
+.phpunit.cache/
+coverage/
config.php
locales/po/*.mo
.omc/
diff --git a/database.php b/database.php
index 38c5707..60dfe11 100644
--- a/database.php
+++ b/database.php
@@ -241,9 +241,10 @@ function flowview_sql_save($array_items, $table_name, $key_cols = 'id', $autoinc
function flowview_db_table_exists($table, $log = true, $cnn_id = false) {
$flowview_cnn = flowview_get_connection($cnn_id);
- preg_match("/([`]{0,1}(?[\w_]+)[`]{0,1}\.){0,1}[`]{0,1}(?[\w_]+)[`]{0,1}/", $table, $matches);
- if ($matches !== false && array_key_exists('table', $matches)) {
- $sql = 'SHOW TABLES LIKE \'' . $matches['table'] . '\'';
+ $matched = preg_match('/^(?:`?(?[A-Za-z0-9_]+)`?\.)?`?(?[A-Za-z0-9_]+)`?$/D', $table, $matches);
+ if ($matched === 1) {
+ $table_pattern = addcslashes($matches['table'], '\\%_');
+ $sql = 'SHOW TABLES LIKE \'' . $table_pattern . '\'';
return (db_fetch_cell($sql, '', $log, $flowview_cnn) ? true : false);
}
diff --git a/functions.php b/functions.php
index 4a35ddd..31bda91 100644
--- a/functions.php
+++ b/functions.php
@@ -2023,8 +2023,6 @@ function load_data_for_filter($id = 0, $start = false, $end = false) {
*
*/
function get_numeric_filter($sql_where, &$sql_params, $value, $column) {
- $values = [];
-
$sql_where = trim($sql_where);
if (is_array($value)) {
@@ -2045,7 +2043,11 @@ function get_numeric_filter($sql_where, &$sql_params, $value, $column) {
}
}
- $sql_where .= ($sql_where != '' ? ' AND ':'WHERE ') . "`$column` IN ($instr)";
+ if ($instr != '') {
+ $sql_where .= ($sql_where != '' ? ' AND ':'WHERE ') . "`$column` IN ($instr)";
+ } else {
+ $sql_where .= ($sql_where != '' ? ' AND ':'WHERE ') . '1 = 0';
+ }
}
return $sql_where;
@@ -2068,11 +2070,8 @@ function get_ip_filter($sql_where, &$sql_params, $value, $column) {
$sql_where = trim($sql_where);
if ($value != '') {
- $values = [];
$parts = explode(',', $value);
- $i = 0;
-
- $sql_where .= ($sql_where != '' ? ' AND ':'WHERE ') . '(';
+ $predicates = array();
foreach($parts as $part) {
$part = trim($part);
@@ -2082,32 +2081,34 @@ function get_ip_filter($sql_where, &$sql_params, $value, $column) {
$addr = cacti_pton($part);
if (isset($addr['subnet'])) {
- // Example looking for IP's in the network to the right: 192.168.11.0/24
- // src_addr & inet6_aton('255.255.255.0') = inet6_aton('192.168.11.0')
- $subnet = inet_ntop($addr['subnet']);
$network = inet_ntop($addr['subnet'] & $addr['ip']);
+ $last = inet_ntop(($addr['subnet'] & $addr['ip']) | ~$addr['subnet']);
- $sql_where .= ($i == 0 ? '':' OR ') .
- "(`$column` & INET6_ATON(?) = INET6_ATON(?) OR `$column` & INET_ATON(?) = INET_ATON(?))";
+ // VARBINARY values cannot be masked reliably with SQL's numeric
+ // bitwise operators. Match the address family and use the
+ // lexicographic range represented by the first and last address.
+ $predicates[] = "(OCTET_LENGTH(`$column`) = OCTET_LENGTH(INET6_ATON(?)) AND `$column` BETWEEN INET6_ATON(?) AND INET6_ATON(?))";
- $sql_params[] = $subnet;
$sql_params[] = $network;
- $sql_params[] = $subnet;
$sql_params[] = $network;
+ $sql_params[] = $last;
} else {
raise_message('subnet_filter', __('Subnet Filter: %s is not a value CIDR format', $part, 'flowview'), MESSAGE_LEVEL_ERROR);
}
- } else {
- $sql_where .= ($i == 0 ? '':' OR ') . "(`$column` = INET6_ATON(?) OR `$column` = INET_ATON(?)";
+ } elseif (is_ipaddress($part)) {
+ $predicates[] = "(`$column` = INET6_ATON(?))";
$sql_params[] = $part;
- $sql_params[] = $part;
+ } else {
+ raise_message('ip_filter', __('IP Filter: %s is not a valid IP address', $part, 'flowview'), MESSAGE_LEVEL_ERROR);
}
-
- $i++;
}
- $sql_where .= ')';
+ if (!empty($predicates)) {
+ $sql_where .= ($sql_where != '' ? ' AND ':'WHERE ') . '(' . implode(' OR ', $predicates) . ')';
+ } else {
+ $sql_where .= ($sql_where != '' ? ' AND ':'WHERE ') . '1 = 0';
+ }
}
return $sql_where;
@@ -3570,7 +3571,11 @@ function run_flow_query($session, $query_id, $start, $end) {
}
function flowview_table_name_to_time($table, $type) {
- $suffix = str_replace('plugin_flowview_raw_', '', $table);
+ if (!preg_match('/^plugin_flowview_raw_([0-9]{7})([0-9]{2})?$/', $table, $matches)) {
+ return false;
+ }
+
+ $suffix = $matches[1] . (isset($matches[2]) ? $matches[2] : '');
$year = substr($suffix, 0, 4);
$day = substr($suffix, 4, 3);
@@ -3580,7 +3585,7 @@ function flowview_table_name_to_time($table, $type) {
$hour = 0;
} else {
$gran = 'hours';
- $hour = substr($suffix, 8, 2);
+ $hour = substr($suffix, 7, 2);
}
$dates = flowview_convert_yeardayhour_to_date($gran, $year, $day, $hour);
@@ -3589,16 +3594,19 @@ function flowview_table_name_to_time($table, $type) {
}
function flowview_convert_yeardayhour_to_date($range, $year, $day, $hour = 0) {
- $datetime = new DateTime();
+ $datetime = new DateTime();
+ $datetime->setDate((int) $year, 1, 1)->setTime(0, 0, 0);
+ $datetime->modify('+' . (int) $day . ' days');
if ($range == 'days') {
- $datetime->setTimestamp(mktime(0, 0, 0, 0, 0, $year) + ($day * 86400));
$start_date = $datetime->format('Y-m-d 00:00:00');
- $end_date = date('Y-m-d 00:00:00', strtotime($start_date)+86400);
+ $datetime->modify('+1 day');
+ $end_date = $datetime->format('Y-m-d 00:00:00');
} else {
- $datetime->setTimestamp(mktime(0, 0, 0, 0, 0, $year) + ($day * 86400) + ($hour * 3600));
+ $datetime->modify('+' . (int) $hour . ' hours');
$start_date = $datetime->format('Y-m-d H:00:00');
- $end_date = date('Y-m-d H:00:00', strtotime($start_date) + ($hour * 3600));
+ $datetime->modify('+1 hour');
+ $end_date = $datetime->format('Y-m-d H:00:00');
}
$start_time = strtotime($start_date);
@@ -4766,11 +4774,7 @@ function flowview_explode($string) {
}
function removeWhiteSpace($string) {
- $string = str_replace("\t", ' ', $string);
- while (substr_count(' ',$string)) {
- $string = str_replace(' ', ' ', $string);
- }
- return $string;
+ return preg_replace('/\s+/', ' ', $string);
}
function plugin_flowview_get_protocol($prot, $prot_hex) {
@@ -6335,14 +6339,17 @@ function flowview_get_owner_from_arin($host) {
}
}
-function flowview_get_domain($host, $domain = 'false') {
- if ($domain == 'false') {
+function flowview_get_domain($host, $domain = false) {
+ if ($domain === false || $domain === 'false') {
return $host;
} elseif (is_ipaddress($host)) {
return $host;
} else {
$parts = explode('.', $host);
$size = cacti_sizeof($parts);
+ if ($size < 2) {
+ return $host;
+ }
return $parts[$size - 2] . '.' . $parts[$size - 1];
}
}
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..9d37d57
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+ tests/Security
+ tests/Unit
+
+
+
+
+
+
+ setup.php
+ functions.php
+ database.php
+
+
+
diff --git a/tests/.cacti-version b/tests/.cacti-version
new file mode 100644
index 0000000..ac6855b
--- /dev/null
+++ b/tests/.cacti-version
@@ -0,0 +1 @@
+1.2.x
\ No newline at end of file
diff --git a/tests/Pest.php b/tests/Pest.php
new file mode 100644
index 0000000..c639e23
--- /dev/null
+++ b/tests/Pest.php
@@ -0,0 +1,14 @@
+ plugins/flowview/tests/.cacti-version
+
+include/vendor/bin/pest --configuration=plugins/flowview/phpunit.xml \
+ plugins/flowview/tests/Security plugins/flowview/tests/Unit
+```
+
+Unit tests load the shipped `functions.php` and `database.php` through a
+small, stateful Cacti API double defined in `tests/bootstrap-unit.php`.
+Database responses can be queued with `plugin_test_queue_db_result()`, and
+every SQL call is recorded in `$GLOBALS['__test_db_calls']` for parameter and
+connection assertions.
+
+Security tests validate PHP 7.4 syntax compatibility and the plugin's
+install/version/uninstall structure without executing plugin source.
+
diff --git a/tests/Security/Php74CompatibilityTest.php b/tests/Security/Php74CompatibilityTest.php
new file mode 100644
index 0000000..4acf6ba
--- /dev/null
+++ b/tests/Security/Php74CompatibilityTest.php
@@ -0,0 +1,118 @@
+getExtension() !== 'php') {
+ continue;
+ }
+
+ $relativeFile = ltrim(str_replace($pluginRoot, '', $file->getPathname()), DIRECTORY_SEPARATOR);
+ $relativeFile = str_replace(DIRECTORY_SEPARATOR, '/', $relativeFile);
+
+ if (strpos($relativeFile, 'tests/') === 0) {
+ continue;
+ }
+
+ $files[] = $relativeFile;
+ }
+
+ sort($files);
+
+ it('does not use str_contains (PHP 8.0)', function () use ($files) {
+ foreach ($files as $relativeFile) {
+ $path = realpath(__DIR__ . '/../../' . $relativeFile);
+
+ if ($path === false) {
+ throw new RuntimeException("Unable to resolve required plugin source");
+ }
+
+ $contents = file_get_contents($path);
+
+ if ($contents === false) {
+ throw new RuntimeException("Unable to read required plugin source");
+ }
+
+ expect(preg_match('/\bstr_contains\s*\(/', $contents))->toBe(0,
+ "{$relativeFile} uses str_contains() which requires PHP 8.0"
+ );
+ }
+ });
+
+ it('does not use str_starts_with (PHP 8.0)', function () use ($files) {
+ foreach ($files as $relativeFile) {
+ $path = realpath(__DIR__ . '/../../' . $relativeFile);
+
+ if ($path === false) {
+ throw new RuntimeException("Unable to resolve required plugin source");
+ }
+
+ $contents = file_get_contents($path);
+
+ if ($contents === false) {
+ throw new RuntimeException("Unable to read required plugin source");
+ }
+
+ expect(preg_match('/\bstr_starts_with\s*\(/', $contents))->toBe(0,
+ "{$relativeFile} uses str_starts_with() which requires PHP 8.0"
+ );
+ }
+ });
+
+ it('does not use str_ends_with (PHP 8.0)', function () use ($files) {
+ foreach ($files as $relativeFile) {
+ $path = realpath(__DIR__ . '/../../' . $relativeFile);
+
+ if ($path === false) {
+ throw new RuntimeException("Unable to resolve required plugin source");
+ }
+
+ $contents = file_get_contents($path);
+
+ if ($contents === false) {
+ throw new RuntimeException("Unable to read required plugin source");
+ }
+
+ expect(preg_match('/\bstr_ends_with\s*\(/', $contents))->toBe(0,
+ "{$relativeFile} uses str_ends_with() which requires PHP 8.0"
+ );
+ }
+ });
+
+ it('does not use nullsafe operator (PHP 8.0)', function () use ($files) {
+ foreach ($files as $relativeFile) {
+ $path = realpath(__DIR__ . '/../../' . $relativeFile);
+
+ if ($path === false) {
+ throw new RuntimeException("Unable to resolve required plugin source");
+ }
+
+ $contents = file_get_contents($path);
+
+ if ($contents === false) {
+ throw new RuntimeException("Unable to read required plugin source");
+ }
+
+ expect(preg_match('/\?->/', $contents))->toBe(0,
+ "{$relativeFile} uses nullsafe operator which requires PHP 8.0"
+ );
+ }
+ });
diff --git a/tests/Security/SetupStructureTest.php b/tests/Security/SetupStructureTest.php
new file mode 100644
index 0000000..059e7a6
--- /dev/null
+++ b/tests/Security/SetupStructureTest.php
@@ -0,0 +1,40 @@
+toContain('function plugin_flowview_install');
+});
+
+it('defines plugin_flowview_version function', function () use ($source) {
+ expect($source)->toContain('function plugin_flowview_version');
+});
+
+it('defines plugin_flowview_uninstall function', function () use ($source) {
+ expect($source)->toContain('function plugin_flowview_uninstall');
+});
+
+it('declares a plugin name in INFO', function () use ($info) {
+ expect($info)->toHaveKey('name');
+});
+
+it('declares a plugin version in INFO', function () use ($info) {
+ expect($info)->toHaveKey('version');
+});
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 0000000..11a195f
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,44 @@
+toBe(' alpha beta gamma ')
+ ->and(flowview_explode(' alpha beta gamma '))->toBe(array('alpha', 'beta', 'gamma'))
+ ->and(flowview_explode(' '))->toBe(array());
+});
+
+it('maps type-of-service bits to their precedence and service labels', function ($tos, $expected) {
+ expect(parse_tos($tos))->toBe($expected);
+})->with(array(
+ array(0, 'Routine'),
+ array(16, 'Low Delay, Routine'),
+ array(40, 'Throughput, Priority'),
+ array(224, 'Net Control'),
+));
+
+it('formats display helpers deterministically', function () {
+ expect(display_domain(''))->toBe('unresolved')
+ ->and(display_domain('example.net'))->toBe('example.net')
+ ->and(get_column_alignment('Bytes'))->toBe('right')
+ ->and(get_column_alignment('Source'))->toBe('left')
+ ->and(flowview_altrow(0))->toBe('even')
+ ->and(flowview_altrow(3))->toBe('odd');
+});
+
+it('serializes only report parameters', function () {
+ $_POST = array(
+ '__csrf_magic' => 'secret',
+ 'domains' => 'ignored',
+ 'table' => 'ignored',
+ 'view' => 'ignored',
+ 'bytes' => 'ignored',
+ 'packets' => 'ignored',
+ 'flows' => 'ignored',
+ 'sourceip' => '192.0.2.1',
+ 'protocols' => array('6', '17'),
+ );
+
+ expect(json_decode(get_json_params(), true))->toBe(array(
+ 'sourceip' => '192.0.2.1',
+ 'protocols' => array('6', '17'),
+ ));
+});
+
+it('derives domains without rewriting IP addresses', function () {
+ expect(flowview_get_rdomain_from_domain('host.eu.example.net'))->toBe('example.net')
+ ->and(flowview_get_rdomain_from_domain('localhost'))->toBe('')
+ ->and(flowview_get_domain('host.eu.example.net', true))->toBe('example.net')
+ ->and(flowview_get_domain('192.0.2.5', true))->toBe('192.0.2.5')
+ ->and(flowview_get_domain('host.eu.example.net'))->toBe('host.eu.example.net');
+});
+
+it('chooses chart scale boundaries', function ($value, $expected) {
+ expect(flowview_autoscale($value))->toBe($expected);
+})->with(array(
+ array(999, array(1, '')),
+ array(1000, array(1000, 'K')),
+ array(1000000, array(1000000, 'M')),
+ array(1000000000, array(1000000000, 'G')),
+ array(1000000000000, array(1000000000000, 'P')),
+));
+
+it('produces stable cache keys and expires only stale sessions', function () {
+ $key = get_flowview_session_key(7, 100, 200);
+ expect($key)->toMatch('/^7_[a-f0-9]{32}$/');
+
+ $_SESSION['sess_flowdata'] = array(
+ 'fresh' => array('timeout' => time() + 60),
+ 'stale' => array('timeout' => time() - 1),
+ );
+ purge_flowview_sessions();
+
+ expect($_SESSION['sess_flowdata'])->toHaveKey('fresh')
+ ->and($_SESSION['sess_flowdata'])->not->toHaveKey('stale');
+});
+
+it('converts daily partition names into exact UTC bounds', function () {
+ expect(flowview_table_name_to_time('plugin_flowview_raw_2024000', 'start_time'))->toBe(strtotime('2024-01-01 00:00:00 UTC'))
+ ->and(flowview_table_name_to_time('plugin_flowview_raw_2024000', 'end_time'))->toBe(strtotime('2024-01-02 00:00:00 UTC'))
+ ->and(flowview_table_name_to_time('invalid_table', 'start_time'))->toBeFalse();
+});
+
+it('converts hourly partition names into one-hour bounds', function () {
+ expect(flowview_table_name_to_time('plugin_flowview_raw_202416412', 'start_time'))->toBe(strtotime('2024-06-13 12:00:00 UTC'))
+ ->and(flowview_table_name_to_time('plugin_flowview_raw_202416412', 'end_time'))->toBe(strtotime('2024-06-13 13:00:00 UTC'));
+});
+
+it('uses local calendar boundaries across daylight-saving changes', function ($timezone, $table, $expectedStart, $expectedEnd) {
+ date_default_timezone_set($timezone);
+
+ expect(flowview_table_name_to_time($table, 'start_time'))->toBe(strtotime($expectedStart))
+ ->and(flowview_table_name_to_time($table, 'end_time'))->toBe(strtotime($expectedEnd));
+})->with(array(
+ array('America/New_York', 'plugin_flowview_raw_202406901', '2024-03-10 01:00:00', '2024-03-10 03:00:00'),
+ array('America/New_York', 'plugin_flowview_raw_202430701', '2024-11-03 01:00:00 EDT', '2024-11-03 02:00:00 EST'),
+ array('Europe/Berlin', 'plugin_flowview_raw_202409001', '2024-03-31 01:00:00', '2024-03-31 03:00:00'),
+));
diff --git a/tests/Unit/DatabaseAdapterTest.php b/tests/Unit/DatabaseAdapterTest.php
new file mode 100644
index 0000000..c8c5373
--- /dev/null
+++ b/tests/Unit/DatabaseAdapterTest.php
@@ -0,0 +1,67 @@
+ 'https'));
+ $row = flowview_db_fetch_row_prepared('SELECT service FROM ports WHERE port = ?', array(443));
+
+ expect($row)->toBe(array('service' => 'https'))
+ ->and($GLOBALS['__test_db_calls'])->toHaveCount(1)
+ ->and($GLOBALS['__test_db_calls'][0]['params'])->toBe(array(443))
+ ->and($GLOBALS['__test_db_calls'][0]['conn'])->toBe('flowview-connection');
+});
+
+it('honors an explicit connection override', function () {
+ plugin_test_queue_db_result('db_fetch_cell_prepared', 7);
+ expect(flowview_db_fetch_cell_prepared('SELECT ?', array(7), '', true, 'replica'))->toBe(7)
+ ->and($GLOBALS['__test_db_calls'][0]['conn'])->toBe('replica');
+});
+
+it('accepts simple optionally-qualified table identifiers', function () {
+ plugin_test_queue_db_result('db_fetch_cell', 1);
+ expect(flowview_db_table_exists('flowdb.plugin_flowview_raw_2024000'))->toBeTrue()
+ ->and($GLOBALS['__test_db_calls'][0]['sql'])->toBe("SHOW TABLES LIKE 'plugin\\_flowview\\_raw\\_2024000'");
+
+});
+
+it('rejects an unsafe or incomplete table identifier', function ($table) {
+ expect(flowview_db_table_exists($table))->toBeFalse()
+ ->and($GLOBALS['__test_db_calls'])->toBe(array());
+})->with(array(
+ 'raw; DROP TABLE users',
+ 'raw table',
+ 'raw`table',
+ 'raw%table',
+ '',
+));
+
+it('generates table DDL with primary, regular, and unique indexes', function () {
+ plugin_test_queue_db_result('db_fetch_assoc', array());
+ $data = array(
+ 'columns' => array(
+ array('name' => 'id', 'type' => 'int(11)', 'unsigned' => true, 'NULL' => false, 'auto_increment' => true),
+ array('name' => 'name', 'type' => 'varchar(64)', 'NULL' => false, 'default' => ''),
+ ),
+ 'primary' => 'id',
+ 'keys' => array(array('name' => 'name_idx', 'columns' => array('name'))),
+ 'unique_keys' => array(array('name' => 'name_unique', 'columns' => array('name'))),
+ 'type' => 'InnoDB',
+ 'charset' => 'utf8mb4',
+ );
+
+ flowview_db_table_create('plugin_flowview_test_ddl', $data);
+ $calls = array_values(array_filter($GLOBALS['__test_db_calls'], function ($call) {
+ return $call['fn'] === 'db_execute';
+ }));
+
+ expect($calls)->toHaveCount(1)
+ ->and($calls[0]['sql'])->toContain('CREATE TABLE IF NOT EXISTS `plugin_flowview_test_ddl`')
+ ->and($calls[0]['sql'])->toContain('PRIMARY KEY (`id`)')
+ ->and($calls[0]['sql'])->toContain('INDEX `name_idx` (`name`)')
+ ->and($calls[0]['sql'])->toContain('UNIQUE INDEX `name_unique` (`name`)')
+ ->and($calls[0]['sql'])->toContain('ENGINE = InnoDB DEFAULT CHARSET = utf8mb4');
+});
diff --git a/tests/Unit/QueryBuilderTest.php b/tests/Unit/QueryBuilderTest.php
new file mode 100644
index 0000000..d4ff8a0
--- /dev/null
+++ b/tests/Unit/QueryBuilderTest.php
@@ -0,0 +1,87 @@
+toBe('WHERE `listener_id` = ? AND `protocol` IN (?, ?)')
+ ->and($params)->toBe(array('existing', '6', '17'))
+ ->and($where)->not->toContain('17');
+});
+
+it('does not modify an empty numeric filter and fails closed on invalid input', function () {
+ $params = array();
+ expect(get_numeric_filter('', $params, '', 'protocol'))->toBe('')
+ ->and(get_numeric_filter('', $params, 'invalid,also-invalid', 'protocol'))->toBe('WHERE 1 = 0')
+ ->and(get_numeric_filter('WHERE `listener_id` = ?', $params, 'invalid', 'protocol'))->toBe('WHERE `listener_id` = ? AND 1 = 0')
+ ->and($params)->toBe(array());
+});
+
+it('builds address predicates with bound IPv4 and IPv6 values', function () {
+ $params = array();
+ $where = get_ip_filter('', $params, '192.0.2.1,2001:db8::1', 'src_addr');
+
+ expect($where)->toBe('WHERE ((`src_addr` = INET6_ATON(?)) OR (`src_addr` = INET6_ATON(?)))')
+ ->and($params)->toBe(array('192.0.2.1', '2001:db8::1'));
+});
+
+it('rejects invalid addresses with a fail-closed predicate', function () {
+ $params = array();
+ expect(get_ip_filter('', $params, 'not-an-address', 'src_addr'))->toBe('WHERE 1 = 0')
+ ->and(get_ip_filter('WHERE `listener_id` = ?', $params, 'also-invalid', 'src_addr'))->toBe('WHERE `listener_id` = ? AND 1 = 0')
+ ->and($params)->toBe(array())
+ ->and($GLOBALS['__test_messages'])->toHaveCount(2);
+});
+
+it('keeps valid addresses when another list entry is invalid', function () {
+ $params = array();
+ $where = get_ip_filter('', $params, '192.0.2.1,bogus', 'src_addr');
+
+ expect($where)->toBe('WHERE ((`src_addr` = INET6_ATON(?)))')
+ ->and($params)->toBe(array('192.0.2.1'))
+ ->and($GLOBALS['__test_messages'])->toHaveCount(1);
+});
+
+it('fails closed when a CIDR prefix is invalid', function () {
+ $params = array();
+
+ expect(get_ip_filter('', $params, '192.0.2.0/99', 'src_addr'))->toBe('WHERE 1 = 0')
+ ->and($params)->toBe(array())
+ ->and($GLOBALS['__test_messages'])->toHaveCount(1);
+});
+
+it('binds CIDR masks and network addresses', function () {
+ $params = array();
+ $where = get_ip_filter('', $params, '192.0.2.0/24', 'dst_addr');
+
+ expect($where)->toContain('OCTET_LENGTH(`dst_addr`) = OCTET_LENGTH(INET6_ATON(?))')
+ ->and($where)->toContain('`dst_addr` BETWEEN INET6_ATON(?) AND INET6_ATON(?)')
+ ->and($params)->toBe(array('192.0.2.0', '192.0.2.0', '192.0.2.255'));
+});
+
+it('computes the full IPv6 CIDR range', function () {
+ $params = array();
+ get_ip_filter('', $params, '2001:db8::/64', 'dst_addr');
+
+ expect($params)->toBe(array('2001:db8::', '2001:db8::', '2001:db8::ffff:ffff:ffff:ffff'));
+});
+
+it('builds each supported temporal overlap contract', function ($rangeType, $expectedSql, $expectedCount) {
+ $params = array();
+ $sql = get_date_filter('', $params, 1704067200, 1704070800, $rangeType);
+
+ expect($sql)->toBe($expectedSql)
+ ->and($params)->toHaveCount($expectedCount)
+ ->and($params[0])->toBe('2024-01-01 00:00:00')
+ ->and($params[1])->toBe('2024-01-01 01:00:00');
+})->with(array(
+ array(1, '(`start_time` BETWEEN ? AND ? OR `end_time` BETWEEN ? AND ?)', 4),
+ array(2, '(`end_time` BETWEEN ? AND ?)', 2),
+ array(3, '(`start_time` BETWEEN ? AND ?)', 2),
+ array(4, '(`start_time` BETWEEN ? AND ? AND `end_time` BETWEEN ? AND ?)', 4),
+));
diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php
new file mode 100644
index 0000000..97d4bcc
--- /dev/null
+++ b/tests/bootstrap-unit.php
@@ -0,0 +1,424 @@
+ $cacti_root,
+ 'url_path' => '/cacti/',
+ 'cacti_version' => $cacti_version,
+ 'cacti_server_os' => 'unix',
+);
+
+$GLOBALS['__test_db_calls'] = array();
+$GLOBALS['__test_db_results'] = array();
+$GLOBALS['__test_config'] = array();
+$GLOBALS['__test_messages'] = array();
+
+if (!function_exists('plugin_test_reset')) {
+ function plugin_test_reset() {
+ $GLOBALS['__test_db_calls'] = array();
+ $GLOBALS['__test_db_results'] = array();
+ $GLOBALS['__test_config'] = array();
+ $GLOBALS['__test_messages'] = array();
+ $_GET = array();
+ $_POST = array();
+ $_REQUEST = array();
+ $_SESSION = array();
+ }
+}
+
+if (!function_exists('plugin_test_queue_db_result')) {
+ function plugin_test_queue_db_result($function, $result) {
+ if (!isset($GLOBALS['__test_db_results'][$function])) {
+ $GLOBALS['__test_db_results'][$function] = array();
+ }
+ $GLOBALS['__test_db_results'][$function][] = $result;
+ }
+}
+
+if (!function_exists('plugin_test_db_result')) {
+ function plugin_test_db_result($function, $default) {
+ if (!empty($GLOBALS['__test_db_results'][$function])) {
+ return array_shift($GLOBALS['__test_db_results'][$function]);
+ }
+ return $default;
+ }
+}
+
+if (!function_exists('db_execute')) {
+ function db_execute($sql, $log = true, $conn = false) {
+ $GLOBALS['__test_db_calls'][] = array('fn' => 'db_execute', 'sql' => $sql, 'params' => array(), 'conn' => $conn);
+ return true;
+ }
+}
+
+if (!function_exists('db_execute_prepared')) {
+ function db_execute_prepared($sql, $params = array(), $log = true, $conn = false) {
+ $GLOBALS['__test_db_calls'][] = array('fn' => 'db_execute_prepared', 'sql' => $sql, 'params' => $params, 'conn' => $conn);
+ return true;
+ }
+}
+
+if (!function_exists('db_fetch_assoc')) {
+ function db_fetch_assoc($sql, $log = true, $conn = false) {
+ $GLOBALS['__test_db_calls'][] = array('fn' => 'db_fetch_assoc', 'sql' => $sql, 'params' => array(), 'conn' => $conn);
+ return plugin_test_db_result('db_fetch_assoc', array());
+ }
+}
+
+if (!function_exists('db_fetch_assoc_prepared')) {
+ function db_fetch_assoc_prepared($sql, $params = array(), $log = true, $conn = false) {
+ $GLOBALS['__test_db_calls'][] = array('fn' => 'db_fetch_assoc_prepared', 'sql' => $sql, 'params' => $params, 'conn' => $conn);
+ return plugin_test_db_result('db_fetch_assoc_prepared', array());
+ }
+}
+
+if (!function_exists('db_fetch_row')) {
+ function db_fetch_row($sql, $log = true, $conn = false) {
+ $GLOBALS['__test_db_calls'][] = array('fn' => 'db_fetch_row', 'sql' => $sql, 'params' => array(), 'conn' => $conn);
+ return plugin_test_db_result('db_fetch_row', array());
+ }
+}
+
+if (!function_exists('db_fetch_row_prepared')) {
+ function db_fetch_row_prepared($sql, $params = array(), $log = true, $conn = false) {
+ $GLOBALS['__test_db_calls'][] = array('fn' => 'db_fetch_row_prepared', 'sql' => $sql, 'params' => $params, 'conn' => $conn);
+ return plugin_test_db_result('db_fetch_row_prepared', array());
+ }
+}
+
+if (!function_exists('db_fetch_cell')) {
+ function db_fetch_cell($sql, $column = '', $log = true, $conn = false) {
+ $GLOBALS['__test_db_calls'][] = array('fn' => 'db_fetch_cell', 'sql' => $sql, 'params' => array(), 'conn' => $conn);
+ return plugin_test_db_result('db_fetch_cell', '');
+ }
+}
+
+if (!function_exists('db_fetch_cell_prepared')) {
+ function db_fetch_cell_prepared($sql, $params = array(), $column = '', $log = true, $conn = false) {
+ $GLOBALS['__test_db_calls'][] = array('fn' => 'db_fetch_cell_prepared', 'sql' => $sql, 'params' => $params, 'conn' => $conn);
+ return plugin_test_db_result('db_fetch_cell_prepared', '');
+ }
+}
+
+if (!function_exists('db_index_exists')) {
+ function db_index_exists($table, $index) {
+ return false;
+ }
+}
+
+if (!function_exists('db_column_exists')) {
+ function db_column_exists($table, $column) {
+ return false;
+ }
+}
+
+if (!function_exists('api_plugin_db_add_column')) {
+ function api_plugin_db_add_column($plugin, $table, $data) {
+ return true;
+ }
+}
+
+if (!function_exists('api_plugin_db_table_create')) {
+ function api_plugin_db_table_create($plugin, $table, $data) {
+ return true;
+ }
+}
+
+if (!function_exists('read_config_option')) {
+ function read_config_option($name, $force = false) {
+ return isset($GLOBALS['__test_config'][$name]) ? $GLOBALS['__test_config'][$name] : '';
+ }
+}
+
+if (!function_exists('set_config_option')) {
+ function set_config_option($name, $value) {
+ $GLOBALS['__test_config'][$name] = $value;
+ }
+}
+
+if (!function_exists('html_escape')) {
+ function html_escape($string) {
+ return htmlspecialchars($string, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+ }
+}
+
+if (!function_exists('__')) {
+ function __($text, $domain = '') {
+ return $text;
+ }
+}
+
+if (!function_exists('__esc')) {
+ function __esc($text, ...$args) {
+ if (!empty($args)) {
+ array_pop($args);
+ }
+ if (!empty($args)) {
+ $text = vsprintf($text, $args);
+ }
+ return htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+ }
+}
+
+if (!function_exists('cacti_log')) {
+ function cacti_log($message, $also_print = false, $log_type = '', $level = 0) {
+ }
+}
+
+if (!function_exists('cacti_sizeof')) {
+ function cacti_sizeof($array) {
+ return is_array($array) ? count($array) : 0;
+ }
+}
+
+if (!function_exists('is_realm_allowed')) {
+ function is_realm_allowed($realm) {
+ return true;
+ }
+}
+
+if (!function_exists('raise_message')) {
+ function raise_message($id, $text = '', $level = 0) {
+ $GLOBALS['__test_messages'][] = array($id, $text, $level);
+ }
+}
+
+if (!function_exists('get_request_var')) {
+ function get_request_var($name) {
+ return isset($_REQUEST[$name]) ? $_REQUEST[$name] : '';
+ }
+}
+
+if (!function_exists('get_nfilter_request_var')) {
+ function get_nfilter_request_var($name) {
+ return get_request_var($name);
+ }
+}
+
+if (!function_exists('get_filter_request_var')) {
+ function get_filter_request_var($name) {
+ return get_request_var($name);
+ }
+}
+
+if (!function_exists('isset_request_var')) {
+ function isset_request_var($name) {
+ return isset($_REQUEST[$name]);
+ }
+}
+
+if (!function_exists('set_request_var')) {
+ function set_request_var($name, $value) {
+ $_REQUEST[$name] = $value;
+ }
+}
+
+if (!function_exists('is_ipaddress')) {
+ function is_ipaddress($value) {
+ return filter_var($value, FILTER_VALIDATE_IP) !== false;
+ }
+}
+
+if (!function_exists('cacti_pton')) {
+ function cacti_pton($cidr) {
+ $parts = explode('/', $cidr, 2);
+ if (count($parts) !== 2 || filter_var($parts[0], FILTER_VALIDATE_IP) === false) {
+ return array();
+ }
+ $ip = inet_pton($parts[0]);
+ $bits = (int) $parts[1];
+ $max = strlen($ip) * 8;
+ if ($bits < 0 || $bits > $max) {
+ return array();
+ }
+ $mask = str_repeat("\xff", intdiv($bits, 8));
+ if ($bits % 8) {
+ $mask .= chr((0xff << (8 - ($bits % 8))) & 0xff);
+ }
+ $mask = str_pad($mask, strlen($ip), "\0");
+ return array('ip' => $ip, 'subnet' => $mask);
+ }
+}
+
+if (!function_exists('db_format_index_create')) {
+ function db_format_index_create($columns) {
+ return implode(', ', array_map(function ($column) {
+ return '`' . $column . '`';
+ }, (array) $columns));
+ }
+}
+
+if (!function_exists('db_get_global_variable')) {
+ function db_get_global_variable($name) {
+ return 'Barracuda';
+ }
+}
+
+if (!function_exists('form_input_validate')) {
+ function form_input_validate($value, $name, $regex, $optional, $error) {
+ return $value;
+ }
+}
+
+if (!function_exists('is_error_message')) {
+ function is_error_message() {
+ return false;
+ }
+}
+
+if (!function_exists('sql_save')) {
+ function sql_save($array, $table, $key = 'id') {
+ return isset($array['id']) ? $array['id'] : 1;
+ }
+}
+
+if (!defined('CACTI_PATH_BASE')) {
+ define('CACTI_PATH_BASE', $GLOBALS['config']['base_path']);
+}
+
+if (!defined('POLLER_VERBOSITY_LOW')) {
+ define('POLLER_VERBOSITY_LOW', 2);
+}
+
+if (!defined('POLLER_VERBOSITY_MEDIUM')) {
+ define('POLLER_VERBOSITY_MEDIUM', 3);
+}
+
+if (!defined('POLLER_VERBOSITY_DEBUG')) {
+ define('POLLER_VERBOSITY_DEBUG', 5);
+}
+
+if (!defined('POLLER_VERBOSITY_NONE')) {
+ define('POLLER_VERBOSITY_NONE', 6);
+}
+
+if (!defined('MESSAGE_LEVEL_ERROR')) {
+ define('MESSAGE_LEVEL_ERROR', 1);
+}
+
+if (!defined('MESSAGE_LEVEL_INFO')) {
+ define('MESSAGE_LEVEL_INFO', 0);
+}
+
+if (!function_exists('plugin_test_read_source')) {
+ function plugin_test_read_source($relative_file) {
+ $path = realpath(__DIR__ . '/../' . $relative_file);
+ if ($path === false) {
+ throw new RuntimeException("Unable to resolve required file: {$relative_file}");
+ }
+
+ $contents = file_get_contents($path);
+ if ($contents === false) {
+ throw new RuntimeException("Unable to read required file: {$relative_file}");
+ }
+
+ return $contents;
+ }
+}
+
+/**
+ * Load a plugin source file at global scope.
+ *
+ * Some plugin files define data as file-scope variables that the rest of
+ * the plugin reads as globals, and they read $config while doing so.
+ * Requiring them from inside a method would make both halves of that
+ * method-local, so the require happens here and any variable the file
+ * introduced is published to $GLOBALS.
+ *
+ * @param string $path Absolute path to the file.
+ *
+ * @return void
+ */
+function flowview_test_load($path) {
+ global $config;
+
+ $__before = get_defined_vars();
+
+ require_once $path;
+
+ foreach (get_defined_vars() as $__name => $__value) {
+ if (!array_key_exists($__name, $__before) && strncmp($__name, '__', 2) !== 0) {
+ $GLOBALS[$__name] = $__value;
+ }
+ }
+}
+
+require_once dirname(__DIR__) . '/functions.php';
+require_once dirname(__DIR__) . '/database.php';