diff options
| author | Simon Rettberg | 2025-11-26 10:46:51 +0100 |
|---|---|---|
| committer | Simon Rettberg | 2025-12-12 15:16:59 +0100 |
| commit | 7c173411785f959d250d3dfbd7d4cfcb0e20f0e0 (patch) | |
| tree | 242157791a76afb7af23ec2cd3d22b599e54ce9d | |
| parent | [exams] Fix incorrect count() clause (diff) | |
| download | slx-admin-7c173411785f959d250d3dfbd7d4cfcb0e20f0e0.tar.gz slx-admin-7c173411785f959d250d3dfbd7d4cfcb0e20f0e0.tar.xz slx-admin-7c173411785f959d250d3dfbd7d4cfcb0e20f0e0.zip | |
Tests generated by Junie AI. Might not have the best possible quality
but at least we got something, and if it turns out to be complete
rubbish, we can just throw it out again without any issues, as this is
independent of the actual code base.
52 files changed, 3245 insertions, 16 deletions
diff --git a/inc/arrayutil.inc.php b/inc/arrayutil.inc.php index 3d93d7d5..ff44b914 100644 --- a/inc/arrayutil.inc.php +++ b/inc/arrayutil.inc.php @@ -8,6 +8,8 @@ class ArrayUtil /** * Take an array of arrays, take given key from each sub-array and return * new array with just those corresponding values. + * If a key is missing from one of the sub-arrays, the entry will be skipped, + * and the resulting array will have fewer entries than the input array. */ public static function flattenByKey(array $list, string $key): array { diff --git a/inc/paginate.inc.php b/inc/paginate.inc.php index 806e2f41..aaafe030 100644 --- a/inc/paginate.inc.php +++ b/inc/paginate.inc.php @@ -47,7 +47,7 @@ class Paginate } // Mangle URL if ($url === null) { - $url = $_SERVER['REQUEST_URI']; + $url = $_SERVER['REQUEST_URI'] ?? ''; } if (strpos($url, '?') === false) { $url .= '?'; diff --git a/inc/user.inc.php b/inc/user.inc.php index 088f12c6..ff367a7f 100644 --- a/inc/user.inc.php +++ b/inc/user.inc.php @@ -4,8 +4,6 @@ declare(strict_types=1); use JetBrains\PhpStorm\NoReturn; -require_once('inc/session.inc.php'); - class User { diff --git a/inc/util.inc.php b/inc/util.inc.php index 003da9fa..9fc1320c 100644 --- a/inc/util.inc.php +++ b/inc/util.inc.php @@ -130,8 +130,14 @@ class Util return Dictionary::number((float)$bytes, $decimals) . "\xe2\x80\x89" . ($sz[$factor + $shift] ?? '#>PiB#'); } + /** + * Make sure given string is a valid filename, by replacing sequences of non-ASCII/printable + * characters by underscore. + */ public static function sanitizeFilename(string $name): string { + if (empty($name)) + return '_'; return preg_replace('/[^a-zA-Z0-9_\-]+/', '_', $name); } @@ -491,7 +497,7 @@ class Util if (!Property::get('webinterface.redirect-domain') || empty(Property::get('webinterface.https-domains'))) return null; // Disabled, or unknown domain $curDomain = $_SERVER['HTTP_HOST']; - if ($curDomain[-1] === '.') { + while ($curDomain[-1] === '.') { $curDomain = substr($curDomain, 0, -1); } $domains = explode(' ', Property::get('webinterface.https-domains')); diff --git a/modules-available/exams/page.inc.php b/modules-available/exams/page.inc.php index 42294990..f42711a2 100644 --- a/modules-available/exams/page.inc.php +++ b/modules-available/exams/page.inc.php @@ -79,13 +79,14 @@ class Page_Exams extends Page protected function readLectures() { $tmp = Database::simpleQuery( - "SELECT lectureid, Group_Concat(locationid) as lids, islocationprivate, displayname, starttime, endtime, isenabled, firstname, lastname, email " . - "FROM sat.lecture " . - "INNER JOIN sat.user ON (user.userid = lecture.ownerid) " . - "NATURAL LEFT JOIN sat.lecture_x_location " . - "WHERE isexam <> 0 AND starttime < :rangeMax AND endtime > :rangeMin " . - "GROUP BY lectureid " . - "ORDER BY starttime ASC, displayname ASC", + "SELECT lectureid, Group_Concat(locationid) as lids, islocationprivate, + displayname, starttime, endtime, isenabled, firstname, lastname, email + FROM sat.lecture sl + INNER JOIN sat.user su ON (su.userid = sl.ownerid) + NATURAL LEFT JOIN sat.lecture_x_location + WHERE isexam <> 0 AND starttime < :rangeMax AND endtime > :rangeMin + GROUP BY lectureid + ORDER BY starttime ASC, displayname ASC", ['rangeMax' => $this->rangeMax, 'rangeMin' => $this->rangeMin]); foreach ($tmp as $lecture) { $this->lectures[] = $lecture; diff --git a/modules-available/permissionmanager/inc/getpermissiondata.inc.php b/modules-available/permissionmanager/inc/getpermissiondata.inc.php index 83752a05..046e6c0e 100644 --- a/modules-available/permissionmanager/inc/getpermissiondata.inc.php +++ b/modules-available/permissionmanager/inc/getpermissiondata.inc.php @@ -47,7 +47,7 @@ class GetPermissionData $res = Database::simpleQuery("SELECT role.roleid AS roleid, rolename, GROUP_CONCAT(COALESCE(locationid, 0)) AS locationids FROM role INNER JOIN role_x_location ON (role.roleid = role_x_location.roleid) - GROUP BY roleid, rolename ORDER BY rolename ASC"); + GROUP BY role.roleid, rolename ORDER BY rolename ASC"); $locations = Location::getLocations(0, 0, true, true); $tree = Location::getLocationsAssoc(); $locations[0]['locationname'] = Dictionary::translate('global', true); diff --git a/modules-available/permissionmanager/inc/permissionutil.inc.php b/modules-available/permissionmanager/inc/permissionutil.inc.php index 2dcd4d3c..862de089 100644 --- a/modules-available/permissionmanager/inc/permissionutil.inc.php +++ b/modules-available/permissionmanager/inc/permissionutil.inc.php @@ -45,6 +45,11 @@ class PermissionUtil /** * Check if the user has the given permission (for the given location). + * Permissions are hierarchical. For example if there is a permission + * called foo.bar.baz, then having the permission foo.* or foo.bar.* + * would match, as well as the obvious exact match. + * Some permissions can apply only to a specific location, while others + * are independent of a location. * * @param int $userid userid to check * @param string $permissionid permissionid to check diff --git a/modules-available/rebootcontrol/api.inc.php b/modules-available/rebootcontrol/api.inc.php index b3e9e976..c230b4c5 100644 --- a/modules-available/rebootcontrol/api.inc.php +++ b/modules-available/rebootcontrol/api.inc.php @@ -4,6 +4,7 @@ if (Request::any('action') === 'rebuild' && isLocalExecution()) { if (Module::isAvailable('sysconfig')) { SSHKey::getPrivateKey($regen); if (!$regen) { + // Was not regenerated, in which case getPrivateKey() would've already called rebuildAllConfigs() ConfigTgz::rebuildAllConfigs(); } echo "OK"; diff --git a/modules-available/rebootcontrol/inc/rebootcontrol.inc.php b/modules-available/rebootcontrol/inc/rebootcontrol.inc.php index 480d2fe9..ec94db63 100644 --- a/modules-available/rebootcontrol/inc/rebootcontrol.inc.php +++ b/modules-available/rebootcontrol/inc/rebootcontrol.inc.php @@ -89,7 +89,7 @@ class RebootControl } /** - * @param int[]|null $locations filter by these locations + * @param int[]|null $locations filter by these locations. Any matching location is enough. * @param ?string $id only with this TaskID * @return array|false list of active tasks for reboots/shutdowns. */ @@ -106,8 +106,8 @@ class RebootControl Property::removeFromListByKey(RebootControl::KEY_TASKLIST, $subkey); continue; } - if (is_array($locations) && is_array($p['locations']) && array_diff($p['locations'], $locations) !== []) - continue; // Not allowed + if (is_array($locations) && is_array($p['locations']) && empty(array_intersect($p['locations'], $locations))) + continue; // No overlap with requested locations if ($id !== null) { if ($p['id'] === $id) return $p; diff --git a/modules-available/rebootcontrol/inc/sshkey.inc.php b/modules-available/rebootcontrol/inc/sshkey.inc.php index e0954415..ba8e3b72 100644 --- a/modules-available/rebootcontrol/inc/sshkey.inc.php +++ b/modules-available/rebootcontrol/inc/sshkey.inc.php @@ -3,15 +3,22 @@ class SSHKey { + /** + * Retrieves the private key from storage or generates a new one if it does not exist. + * + * @param bool|null &$regen A reference parameter that indicates whether a new private + * key was generated (true if regenerated, false otherwise). + * @return string|null Returns the private key as a string if successful, or null if the key could not be generated. + */ public static function getPrivateKey(?bool &$regen = false): ?string { + $regen = false; $privKey = Property::get("rebootcontrol-private-key"); if (!$privKey) { $rsaKey = openssl_pkey_new([ 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); if (!openssl_pkey_export( openssl_pkey_get_private($rsaKey), $privKey)) { - $regen = false; return null; } Property::set("rebootcontrol-private-key", $privKey); diff --git a/modules-available/rebootcontrol/pages/task.inc.php b/modules-available/rebootcontrol/pages/task.inc.php index 7db2a90b..67a747c5 100644 --- a/modules-available/rebootcontrol/pages/task.inc.php +++ b/modules-available/rebootcontrol/pages/task.inc.php @@ -75,6 +75,7 @@ class SubPage } if (!empty($job['locations'])) { $allowedLocs = User::getAllowedLocations("action.$perm"); + // Need to have permission for all affected locations to see job details if (!in_array(0, $allowedLocs) && array_diff($job['locations'], $allowedLocs) !== []) { Message::addError('main.no-permission'); return; diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 00000000..07aad8ce --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<phpunit bootstrap="tests/bootstrap.php" + colors="true" + stopOnFailure="false" + cacheResult="false"> + <testsuites> + <testsuite name="Project Test Suite"> + <directory>tests</directory> + </testsuite> + </testsuites> + <php> + <ini name="error_reporting" value="-1"/> + <ini name="display_errors" value="1"/> + </php> +</phpunit> diff --git a/tests/Inc/ArrayUtilTest.php b/tests/Inc/ArrayUtilTest.php new file mode 100644 index 00000000..0423e6da --- /dev/null +++ b/tests/Inc/ArrayUtilTest.php @@ -0,0 +1,91 @@ +<?php + +use PHPUnit\Framework\TestCase; + +class ArrayUtilTest extends TestCase +{ + public function testFlattenByKeyBasicAndMissingKeys(): void + { + $in = [ + ['id' => 1, 'name' => 'a'], + ['id' => 2, 'name' => 'b', 'x' => 5], + ['name' => 'c'], // missing 'id' -> skipped by array_column() + ]; + $this->assertSame([1, 2], ArrayUtil::flattenByKey($in, 'id')); + } + + public function testMergeByKeyMergesAcrossSubArrays(): void + { + $in = [ + 'a' => ['k1' => 1, 'k2' => 2], + 'b' => ['k1' => 3], + ]; + $expected = [ + 'k1' => ['a' => 1, 'b' => 3], + 'k2' => ['a' => 2, 'b' => false], + ]; + $this->assertSame($expected, ArrayUtil::mergeByKey($in)); + } + + public function testSortByColumnAscendingAndDescending(): void + { + $rows = [ + ['n' => 'b', 'v' => 2], + ['n' => 'a', 'v' => 3], + ['n' => 'c', 'v' => 1], + ]; + $copy = $rows; + ArrayUtil::sortByColumn($rows, 'v', SORT_ASC); + $this->assertSame([ + ['n' => 'c', 'v' => 1], + ['n' => 'b', 'v' => 2], + ['n' => 'a', 'v' => 3], + ], $rows); + + ArrayUtil::sortByColumn($copy, 'n', SORT_DESC, SORT_STRING); + $this->assertSame([ + ['n' => 'c', 'v' => 1], + ['n' => 'b', 'v' => 2], + ['n' => 'a', 'v' => 3], + ], $copy); + } + + public function testHasAllKeys(): void + { + $arr = ['a' => 1, 'b' => 2]; + $this->assertTrue(ArrayUtil::hasAllKeys($arr, ['a', 'b'])); + $this->assertFalse(ArrayUtil::hasAllKeys($arr, ['a', 'c'])); + $this->assertTrue(ArrayUtil::hasAllKeys($arr, [])); + } + + public function testIsOnlyPrimitiveTypes(): void + { + $this->assertTrue(ArrayUtil::isOnlyPrimitiveTypes([1, 'x', 1.2, null, true, false, 0])); + $this->assertFalse(ArrayUtil::isOnlyPrimitiveTypes([[]])); + $this->assertFalse(ArrayUtil::isOnlyPrimitiveTypes([new stdClass()])); + + $h = fopen('php://memory', 'r'); + try { + $this->assertFalse(ArrayUtil::isOnlyPrimitiveTypes([$h])); + } finally { + if (is_resource($h)) + fclose($h); + } + } + + public function testForceTypeMutatesArrayInPlace(): void + { + $arr = ['1', '2', '3']; + ArrayUtil::forceType($arr, 'int'); + $this->assertSame([1, 2, 3], $arr); + + $arr = ['0', '1', '', 'foo']; + ArrayUtil::forceType($arr, 'bool'); + // In PHP, (bool) '0' is false; non-empty strings except '0' are true; empty string is false + $this->assertSame([false, true, false, true], $arr); + + $arr = [1, 2, 3]; + ArrayUtil::forceType($arr, 'string'); + $this->assertSame(['1', '2', '3'], $arr); + } +} diff --git a/tests/Inc/AuditTest.php b/tests/Inc/AuditTest.php new file mode 100644 index 00000000..971b4e9a --- /dev/null +++ b/tests/Inc/AuditTest.php @@ -0,0 +1,101 @@ +<?php + +use PHPUnit\Framework\TestCase; + +/** + * Audit tests migrated to the SQLite-backed Database backend to assert against real SQL rows. + * + */ +class AuditTest extends TestCase +{ + protected function setUp(): void + { + Database::resetSchema(); + // Reset stubs and superglobals + User::reset(); + ErrorHandler::reset(); + $_POST = []; + $_REQUEST = []; + } + + public function testRunInsertsFilteredPostDataAndMetadata(): void + { + // Arrange environment + User::$loggedIn = true; + User::$id = 123; + $_SERVER['REMOTE_ADDR'] = '203.0.113.5'; + $_REQUEST['action'] = 'update'; + + // Build POST with sensitive keys and nested arrays + $_POST = [ + 'username' => 'alice', + 'password' => 'secret', // censored to ***** + 'apiToken' => 'tok-123', // censored to ***** + 'my_privatekey' => 'abc', // censored to ***** (ends with key) + 'pw_hint' => 'hello', // censored to ***** (starts with pw) + 'prevent_autofill' => 'x', // skipped entirely + 'action' => 'should-be-skipped-in-data', // skipped in data (but appears in metadata via $_REQUEST) + 'arr' => [ + 'nestedPassword' => 'verysecret', // censored in nested + 'value' => 'ok', + ], + 'long' => str_repeat('A', 1200), // will be truncated to 1000 + ... initially; still under maxTotalLen -> accepted + ]; + + // Act + Audit::run('mod.settings'); + + // Assert: fetch the last audit row + $row = Database::queryFirst('SELECT * FROM audit ORDER BY id DESC LIMIT 1'); + $this->assertNotFalse($row); + $this->assertSame(123, (int)$row['userid']); + $this->assertSame('203.0.113.5', $row['ipaddr']); + $this->assertSame('mod.settings', $row['module']); + $this->assertSame('update', $row['action']); + + // Decode filtered JSON + $filtered = json_decode($row['data'], true); + $this->assertIsArray($filtered); + // Skipped keys not present + $this->assertArrayNotHasKey('prevent_autofill', $filtered); + $this->assertArrayNotHasKey('action', $filtered); + // Censored keys + $this->assertSame('*****', $filtered['password']); + $this->assertSame('*****', $filtered['apiToken']); + $this->assertSame('*****', $filtered['my_privatekey']); + $this->assertSame('*****', $filtered['pw_hint']); + // Nested censoring preserved + $this->assertSame('*****', $filtered['arr']['nestedPassword']); + $this->assertSame('ok', $filtered['arr']['value']); + // Truncation behavior for long field: ends with ellipsis and length > 1000 due to ... + $this->assertStringEndsWith('...', $filtered['long']); + $this->assertSame(1003, strlen($filtered['long'])); + } + + public function testRunExcessPayloadTriggersTraceAndStoresEXCESS(): void + { + // Not logged in -> smaller total limit (10000) + User::$loggedIn = false; + User::$id = 7; + $_SERVER['REMOTE_ADDR'] = '198.51.100.9'; + $_REQUEST['action'] = 'bulk'; + + // Build a very large POST that even after truncation to 200 still exceeds 10000 + $big = []; + for ($i = 0; $i < 60; $i++) { + $big['f' . $i] = str_repeat('x', 5000); + } + $_POST = $big; + + Audit::run('mod.big'); + + // Assert row stored with EXCESS data marker + $row = Database::queryFirst('SELECT * FROM audit ORDER BY id DESC LIMIT 1'); + $this->assertNotFalse($row); + $this->assertSame('EXCESS', $row['data']); + + // ErrorHandler should record the trace + $this->assertNotEmpty(ErrorHandler::$traces); + $this->assertStringContainsString('exceeded', strtolower(ErrorHandler::$traces[0])); + } +} diff --git a/tests/Inc/IpUtilTest.php b/tests/Inc/IpUtilTest.php new file mode 100644 index 00000000..8c65a0c3 --- /dev/null +++ b/tests/Inc/IpUtilTest.php @@ -0,0 +1,60 @@ +<?php + +use PHPUnit\Framework\TestCase; + +class IpUtilTest extends TestCase +{ + public function testParseCidrWithSlashNotation(): void + { + $res = IpUtil::parseCidr('192.168.1/24'); + $this->assertNotNull($res); + $this->assertSame(ip2long('192.168.1.0'), $res['start']); + $this->assertSame(ip2long('192.168.1.255'), $res['end']); + } + + public function testParseCidrSingleIp(): void + { + $res = IpUtil::parseCidr('10.0.0.1'); + $this->assertNotNull($res); + $this->assertSame(ip2long('10.0.0.1'), $res['start']); + $this->assertSame(ip2long('10.0.0.1'), $res['end']); + } + + public function testParseCidrInvalid(): void + { + $this->assertNull(IpUtil::parseCidr('1.2.3.4/33')); + $this->assertNull(IpUtil::parseCidr('not-an-ip')); + } + + public function testIsValidSubnetRange(): void + { + $start = ip2long('192.168.0.0'); + $end = ip2long('192.168.0.255'); + $this->assertTrue(IpUtil::isValidSubnetRange($start, $end)); + + $start = ip2long('192.168.0.1'); + $end = ip2long('192.168.0.254'); + $this->assertFalse(IpUtil::isValidSubnetRange($start, $end)); + + // single IP should be considered a valid /32 range + $ip = ip2long('10.0.0.1'); + $this->assertTrue(IpUtil::isValidSubnetRange($ip, $ip)); + } + + public function testRangeToCidrValid(): void + { + $start = ip2long('192.168.2.0'); + $end = ip2long('192.168.2.255'); + $this->assertSame('192.168.2.0/24', IpUtil::rangeToCidr($start, $end)); + } + + public function testRangeToCidrNotSubnet(): void + { + $start = ip2long('192.168.5.1'); + $end = ip2long('192.168.5.254'); + $this->assertSame( + 'NOT SUBNET: 192.168.5.1-192.168.5.254', + IpUtil::rangeToCidr($start, $end) + ); + } +} diff --git a/tests/Inc/MailerTest.php b/tests/Inc/MailerTest.php new file mode 100644 index 00000000..e1b361f6 --- /dev/null +++ b/tests/Inc/MailerTest.php @@ -0,0 +1,74 @@ +<?php + +use PHPUnit\Framework\TestCase; + +/** + * Mailer tests using the SQLite-backed Database test backend (no MySQL required). + * + */ +class MailerTest extends TestCase +{ + protected function setUp(): void + { + Database::resetSchema(); + EventLog::reset(); + } + + public function testQueueInsertsOneRowPerRecipient(): void + { + Mailer::queue(5, ['a@example.com', 'b@example.com'], 'Hello', "Body\ntext"); + + $rows = Database::queryAll('SELECT rcpt, subject, body, configid FROM mail_queue ORDER BY rcpt'); + $this->assertCount(2, $rows); + $this->assertSame('a@example.com', $rows[0]['rcpt']); + $this->assertSame('b@example.com', $rows[1]['rcpt']); + foreach ($rows as $r) { + $this->assertSame('Hello', $r['subject']); + $this->assertSame("Body\ntext", $r['body']); + $this->assertSame(5, (int)$r['configid']); + } + } + + public function testFlushQueueDropsTooOldMailsAndDeletesWithoutSending(): void + { + $tooOld = time() - 50000; // older than 12h cutoff used in flushQueue + Database::exec('INSERT INTO mail_queue (configid, rcpt, subject, body, dateline, nexttry) VALUES (:configid, :rcpt, :subject, :body, :dateline, 0)', [ + 'configid' => 1, + 'rcpt' => 'user@example.org', + 'subject' => 'Old Notice', + 'body' => 'Old body', + 'dateline' => $tooOld, + ]); + + Mailer::flushQueue(); + + $cnt = Database::queryFirst('SELECT COUNT(*) AS c FROM mail_queue'); + $this->assertSame(0, (int)$cnt['c'], 'Too-old mails should be deleted'); + + // EventLog::info should contain the drop message + $this->assertNotEmpty(EventLog::$info); + $this->assertStringContainsString('Dropping queued mail', EventLog::$info[0][0]); + } + + public function testFlushQueueWithInvalidConfigLogsFailureAndDeletes(): void + { + $recent = time(); + // Insert one recent mail referencing a non-existent config (42) + Database::exec('INSERT INTO mail_queue (configid, rcpt, subject, body, dateline, nexttry) VALUES (:configid, :rcpt, :subject, :body, :dateline, 0)', [ + 'configid' => 42, + 'rcpt' => 'rcpt@example.org', + 'subject' => 'Test Subject', + 'body' => 'First body', + 'dateline' => $recent, + ]); + + Mailer::flushQueue(); + + // Should log one failure about invalid config id + $this->assertNotEmpty(EventLog::$failure); + $this->assertStringContainsString('Invalid mailer config id', EventLog::$failure[0]); + + $cnt = Database::queryFirst('SELECT COUNT(*) AS c FROM mail_queue'); + $this->assertSame(0, (int)$cnt['c'], 'Mail should be deleted when config is invalid'); + } +} diff --git a/tests/Inc/ModuleTest.php b/tests/Inc/ModuleTest.php new file mode 100644 index 00000000..05791022 --- /dev/null +++ b/tests/Inc/ModuleTest.php @@ -0,0 +1,188 @@ +<?php + +use PHPUnit\Framework\TestCase; + +/** + * Tests for the global Module loader include (inc/module.inc.php). + * + * These tests exercise dependency resolution, activation/autoloading, page loading, + * and the various query helpers (getEnabled/getAll/getActivated/getDependencies,...). + * + * We create temporary test modules under ./modules with minimal config.json, + * page.inc.php, and inc/*.inc.php files, then clean them up in tearDown(). + * + * @runTestsInSeparateProcesses + * @preserveGlobalState disabled + */ +class ModuleTest extends TestCase +{ + private string $baseDir; + private array $created = []; + + protected function setUp(): void + { + ErrorHandler::reset(); + $this->baseDir = getcwd() . '/modules'; + // Ensure a clean environment: the Module registry initializes lazily in Module::init(), + // and we haven't referenced Module yet in this process. + $this->makeModule('TestDep', [ + 'dependencies' => [], + 'client-plugin' => false, + 'category' => 'utilities', + ]); + $this->writeFile('modules/TestDep/page.inc.php', + "<?php\nclass Page_TestDep extends Page { protected function doPreprocess(){} protected function doRender(){} }\n"); + // A helper class to be autoloaded only when TestMain is activated + $this->makeModule('TestMain', [ + 'dependencies' => ['testdep'], // must reference the lowercase id key + 'client-plugin' => true, + 'category' => 'main', + 'css' => [], + 'scripts' => [], + 'collapse' => true, + ]); + $this->writeFile('modules/TestMain/page.inc.php', + "<?php\nclass Page_TestMain extends Page { protected function doPreprocess(){} protected function doRender(){} }\n"); + $this->writeFile('modules/TestMain/inc/abchelper.inc.php', + "<?php\nclass AbcHelper { public static function id(){ return 123; } }\n"); + // Provide optional assets that getCss/getScripts may include when activated + $this->writeFile('modules/TestMain/style.css', '/* css */'); + $this->writeFile('modules/TestMain/clientscript.js', '// js'); + + // A module with a missing dependency to test failure path + $this->makeModule('BadMain', [ 'dependencies' => ['no_such_dep'] ]); + $this->writeFile('modules/BadMain/page.inc.php', + "<?php\nclass Page_BadMain extends Page { protected function doPreprocess(){} protected function doRender(){} }\n"); + } + + protected function tearDown(): void + { + // Remove created test modules + // Iterate in reverse to delete children before parents + foreach (array_reverse($this->created) as $path) { + if (is_file($path)) { + @unlink($path); + } elseif (is_dir($path)) { + @rmdir($path); + } + } + $this->created = []; + } + + private function makeModule(string $name, array $config): void + { + $dir = $this->baseDir . '/' . $name; + $inc = $dir . '/inc'; + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + $this->created[] = $dir; + } + if (!is_dir($inc)) { + mkdir($inc, 0777, true); + $this->created[] = $inc; + } + $this->writeFile($dir . '/config.json', json_encode($config)); + } + + private function writeFile(string $path, string $content): void + { + file_put_contents($path, $content); + $this->created[] = $path; + } + + public function testInitAndGetAndActivationAndNewPage(): void + { + // Load module list from filesystem + require_once 'inc/module.inc.php'; + Module::init(); + + // Resolve/activate and check availability + $this->assertTrue(Module::isAvailable('testmain', true)); + $main = Module::get('testmain'); + $this->assertNotFalse($main); + $this->assertSame('TestMain', $main->getIdentifier()); + // getDisplayName falls back to !!name!! without dictionary entry + $this->assertSame('!!TestMain!!', $main->getDisplayName()); + // Category helpers + $this->assertSame('main', $main->getCategory()); + $this->assertSame('Cat:main', $main->getCategoryName()); + $this->assertTrue($main->doCollapse()); + $this->assertSame('modules/TestMain', $main->getDir()); + + // Test autoloader from module/inc + $this->assertTrue(class_exists('AbcHelper'), 'Autoloader should expose AbcHelper from module/inc'); + $this->assertSame(123, AbcHelper::id()); + + // Page loader + $page = $main->newPage(); + $this->assertInstanceOf(Page::class, $page); + $this->assertInstanceOf(Page_TestMain::class, $page); + + // Assets only reported when directly activated and client-plugin true + $css = $main->getCss(); + $js = $main->getScripts(); + $this->assertContains('style.css', $css); + $this->assertContains('clientscript.js', $js); + } + + public function testDependencyResolutionAndQueries(): void + { + require_once 'inc/module.inc.php'; + Module::init(); + + // BadMain is not available due to missing dep + // The Module loader triggers a user-level warning in this case; expect it so the test doesn't fail + set_error_handler(function ($errno, $errstr, $errfile, $errline) { + if ($errstr === 'Disabling module BadMain: Dependency no_such_dep failed.') + return; + throw new ErrorException($errstr, 0, $errno, $errfile, $errline); + }); + $this->assertFalse(Module::isAvailable('badmain')); + $this->assertFalse(Module::get('badmain')); + + // TestDep alone is available + $this->assertTrue(Module::isAvailable('testdep')); + $dep = Module::get('testdep'); + $this->assertNotFalse($dep); + + // Enabled list contains both TestDep and TestMain when we activate main + Module::isAvailable('testmain', true); + $enabled = Module::getEnabled(true); + $ids = array_map(function($m){ return $m->getIdentifier(); }, $enabled); + $this->assertContains('TestDep', $ids); + $this->assertContains('TestMain', $ids); + + // All includes even those with missing deps (after resolution attempt) + $all = Module::getAll(); + $allIds = array_map(function($m){ return $m->getIdentifier(); }, $all); + $this->assertContains('TestDep', $allIds); + $this->assertContains('TestMain', $allIds); + $this->assertContains('BadMain', $allIds); // present but unusable + + // Activated contains modules with an activated depth marker + $activated = Module::getActivated(); + $this->assertNotEmpty($activated); + $actVals = array_values($activated); + $this->assertContainsOnlyInstancesOf(Module::class, $actVals); + } + + public function testTransitiveDependenciesListed(): void + { + // Create Dep2 and make TestDep depend on it; then init fresh process registry + $this->makeModule('Dep2', [ 'dependencies' => [] ]); + $this->writeFile('modules/Dep2/page.inc.php', + "<?php\nclass Page_Dep2 extends Page { protected function doPreprocess(){} protected function doRender(){} }\n"); + // Overwrite TestDep config to depend on dep2 + $this->writeFile('modules/TestDep/config.json', json_encode(['dependencies' => ['dep2']])); + + require_once 'inc/module.inc.php'; + Module::init(); + // Activate main (which depends on testdep which depends on dep2) + Module::isAvailable('testmain', true); + $main = Module::get('testmain'); + $list = $main->getDependencies(); + // Should include transitive dependency + $this->assertContains('testdep', $list); + $this->assertContains('dep2', $list); + } +} diff --git a/tests/Inc/UserTest.php b/tests/Inc/UserTest.php new file mode 100644 index 00000000..e9ca2210 --- /dev/null +++ b/tests/Inc/UserTest.php @@ -0,0 +1,120 @@ +<?php + +use PHPUnit\Framework\TestCase; + +/** + * Demonstrates how to test a real class when a stub exists: opt out of the stub + * via the stub autoloader allowlist and let the normal inc/ autoloader load the + * production class instead. Now adapted to use the SQLite-backed Database test + * backend so we assert against real SQL, not stub call logs. + * + * @runTestsInSeparateProcesses + * @preserveGlobalState disabled + */ +class UserTest extends TestCase +{ + protected function setUp(): void + { + $GLOBALS['__TEST_USE_REAL_CLASSES'] = ['User']; + + Database::resetSchema(); + Session::reset(); + } + + public function testBasicAccessorsLoggedOutAndLoggedIn(): void + { + // Sanity: manipulate private static User::$user via reflection + $ref = new ReflectionClass('User'); + $prop = $ref->getProperty('user'); + $prop->setAccessible(true); + + // Start logged out + $prop->setValue(null, false); + $this->assertFalse(User::isLoggedIn()); + $this->assertNull(User::getId()); + $this->assertFalse(User::getName()); + $this->assertNull(User::getLogin()); + + // Now set a minimal user record + $user = [ + 'userid' => 42, + 'fullname' => 'Alice Doe', + 'login' => 'alice', + 'permissions' => 0, + 'lasteventid' => null, + ]; + $prop->setValue(null, $user); + + $this->assertTrue(User::isLoggedIn()); + $this->assertSame(42, User::getId()); + $this->assertSame('Alice Doe', User::getName()); + $this->assertSame('alice', User::getLogin()); + } + + public function testSetAndGetLastSeenEventUpdatesDatabaseAndMemory(): void + { + $ref = new ReflectionClass('User'); + $prop = $ref->getProperty('user'); + $prop->setAccessible(true); + // Use an existing seeded user (id=1) + $prop->setValue(null, [ + 'userid' => 1, + 'fullname' => 'Alice Doe', + 'login' => 'alice', + 'permissions' => 0, + 'lasteventid' => 1, + ]); + + User::setLastSeenEvent(1234); + + // Assert DB has been updated + $row = Database::queryFirst('SELECT lasteventid FROM user WHERE userid = :u', ['u' => 1]); + $this->assertNotFalse($row); + $this->assertSame(1234, (int)$row['lasteventid']); + + // And memory updated too + $this->assertSame(1234, User::getLastSeenEvent()); + } + + public function testUpdatePasswordUsesCryptoAndExecutesUpdate(): void + { + $ref = new ReflectionClass('User'); + $prop = $ref->getProperty('user'); + $prop->setAccessible(true); + // Use seeded user id=1 + $prop->setValue(null, [ + 'userid' => 1, + 'fullname' => 'Alice Doe', + 'login' => 'alice', + 'permissions' => 0, + 'lasteventid' => null, + ]); + + // Act + $ret = User::updatePassword('secret'); + $this->assertTrue($ret); + + // Assert DB updated with hashed password from Crypto stub + $row = Database::queryFirst('SELECT passwd FROM user WHERE userid = :u', ['u' => 1]); + $this->assertNotFalse($row); + $this->assertSame('HASHED-secret', $row['passwd']); + } + + public function testLoginVerifiesPasswordAndCreatesSession(): void + { + $_SERVER['REMOTE_ADDR'] = '192.168.127.12'; + $_SERVER['REMOTE_PORT'] = 12345; + $_SERVER['HTTP_USER_AGENT'] = 'foobar'; + Session::reset(); + + // Good password (Crypto::verify will accept 'ok' against 'STORED' from seed) + $this->assertTrue(User::login('alice', 'ok', true)); + $this->assertNotNull(Session::$lastCreate); + $this->assertSame(1, Session::$lastCreate['userId']); + + // Bad password + Session::reset(); + $this->assertFalse(User::login('alice', 'bad', false)); + $this->assertNull(Session::$lastCreate); + } +} diff --git a/tests/Inc/UtilTest.php b/tests/Inc/UtilTest.php new file mode 100644 index 00000000..63f60c7e --- /dev/null +++ b/tests/Inc/UtilTest.php @@ -0,0 +1,205 @@ +<?php + +use PHPUnit\Framework\TestCase; + +class UtilTest extends TestCase +{ + protected function setUp(): void + { + // Reset shared stubs state + Message::reset(); + Session::reset(); + User::reset(); + Property::reset(); + } + + public function testSanitizeFilename(): void + { + $this->assertSame('file_name_txt', Util::sanitizeFilename('file name.txt')); + $this->assertSame('a_b_c_bc_123', Util::sanitizeFilename("a/b\\c:*?<>|\"\t\näbc 123")); + $this->assertSame('_', Util::sanitizeFilename('äöü')); + $this->assertSame('_', Util::sanitizeFilename('')); + } + + public function testSafePathBasic(): void + { + $this->assertNull(Util::safePath('')); + $this->assertNull(Util::safePath('/etc/passwd')); + $this->assertNull(Util::safePath("foo\x01bar")); + $this->assertNull(Util::safePath('a/../b')); + + $this->assertSame('./foo/bar', Util::safePath('foo/bar')); + $this->assertSame('./foo/bar', Util::safePath('./foo/bar')); + } + + public function testSafePathWithPrefix(): void + { + $this->assertSame('./data/file.txt', Util::safePath('data/file.txt', 'data')); + $this->assertSame('./data/file.txt', Util::safePath('./data/file.txt', './data')); + $this->assertNull(Util::safePath('other/file.txt', 'data')); + } + + public function testReadableFileSize(): void + { + // Bytes + $this->assertSame("0\xE2\x80\x89Byte", Util::readableFileSize(0)); + $this->assertSame("999\xE2\x80\x89Byte", Util::readableFileSize(999)); + // KiB + $this->assertSame("1.00\xE2\x80\x89KiB", Util::readableFileSize(1024)); + $this->assertSame("1.95\xE2\x80\x89KiB", Util::readableFileSize(2000)); + // MiB with shift (pretend given is KiB) + $this->assertSame("1.00\xE2\x80\x89MiB", Util::readableFileSize(1024, -1, 1)); + } + + public function testVerifyToken(): void + { + // Not logged in and no session token -> allowed + User::$loggedIn = false; + Session::$store = []; // get('token') -> false + $this->assertTrue(Util::verifyToken()); + + // Logged in with matching token -> allowed + User::$loggedIn = true; + Session::$store['token'] = 'abc'; + $_REQUEST['token'] = 'abc'; + $this->assertTrue(Util::verifyToken()); + + // Logged in with missing/invalid token -> error and false + Message::reset(); + $_REQUEST['token'] = 'xyz'; + $this->assertFalse(Util::verifyToken()); + $this->assertTrue(in_array('main.token', Message::$errors, true)); + } + + public function testUploadErrorStringIncludesUnknown(): void + { + $this->assertSame('The uploaded file exceeds the upload_max_filesize directive in php.ini', Util::uploadErrorString(UPLOAD_ERR_INI_SIZE)); + $this->assertSame('No file was uploaded', Util::uploadErrorString(UPLOAD_ERR_NO_FILE)); + $this->assertSame('Unknown upload error', Util::uploadErrorString(9999)); + } + + public function testIsPublicIpv4(): void + { + $this->assertTrue(Util::isPublicIpv4('8.8.8.8')); + $this->assertFalse(Util::isPublicIpv4('10.0.0.1')); + $this->assertFalse(Util::isPublicIpv4('192.168.1.1')); + $this->assertFalse(Util::isPublicIpv4('172.16.0.1')); + $this->assertTrue(Util::isPublicIpv4('172.15.0.1')); + $this->assertTrue(Util::isPublicIpv4('172.32.0.1')); + $this->assertFalse(Util::isPublicIpv4('127.0.0.1')); + $this->assertFalse(Util::isPublicIpv4('0.0.0.0')); + $this->assertFalse(Util::isPublicIpv4('256.0.0.1')); + $this->assertFalse(Util::isPublicIpv4('1.2.3')); + } + + public function testMarkupEscapesAndFormats(): void + { + $in = '*bold* _under_ /italics/ <b>raw</b>' . "\n" . 'next'; + $out = Util::markup($in); + $this->assertStringContainsString('<b>bold</b>', $out); + $this->assertStringContainsString('<u>under</u>', $out); + $this->assertStringContainsString('<i>italics</i>', $out); + // raw HTML must be escaped + $this->assertStringContainsString('<b>raw</b>', $out); + // newline converted to <br /> + $this->assertStringContainsString('<br', $out); + } + + public function testPrettyTimeAndBoolToString(): void + { + $now = time(); + $outToday = Util::prettyTime($now); + $this->assertStringContainsString('today', $outToday); + + $yesterday = $now - 86400 + 60; // ensure within yesterday day window + $outY = Util::prettyTime($yesterday); + $this->assertStringContainsString('yesterday', $outY); + + $this->assertSame('Yes', Util::boolToString(true)); + $this->assertSame('No', Util::boolToString(false)); + } + + public function testFormatDuration(): void + { + $this->assertSame('1y 00:00:00', Util::formatDuration(31536000)); + $this->assertSame('1y 01mon 00:00:00', Util::formatDuration(31536000 + 2592000)); + $this->assertSame('1y 01mon 01d 00:00:00', Util::formatDuration(31536000 + 2592000 + 86400)); + $this->assertSame('00:01:05', Util::formatDuration(65)); + $this->assertSame('00:01', Util::formatDuration(65, false)); + } + + public function testCleanUtf8AndAnsiToUtf8(): void + { + $bad = "Hello\xC3World\xFF"; // invalid sequences + $clean = Util::cleanUtf8($bad); + $this->assertStringContainsString('Hello', $clean); + $this->assertStringNotContainsString("\xFF", $clean); + + $ansi = "Hi\x01\x02\x03Mehr"; // control chars removed + $out = Util::ansiToUtf8($ansi); + $this->assertStringNotContainsString("\x01", $out); + $this->assertStringContainsString('HiMehr', $out); + } + + public function testClamp(): void + { + $v = 5; + Util::clamp($v, 1, 10); + $this->assertSame(5, $v); + $v = -1; + Util::clamp($v, 1, 10); + $this->assertSame(1, $v); + $v = 99; + Util::clamp($v, 1, 10); + $this->assertSame(10, $v); + + $v = 5.5; + Util::clamp($v, 1, 10, false); + $this->assertIsFloat($v); + $this->assertSame(5.5, $v); + } + + public function testShouldRedirectDomain(): void + { + Property::$values['webinterface.redirect-domain'] = 1; + Property::$values['webinterface.https-domains'] = 'admin.example.org *.example.org other.tld'; + // Current host not matching -> expect first domain + $_SERVER['HTTP_HOST'] = 'some.other.domain.com'; + $this->assertSame('admin.example.org', Util::shouldRedirectDomain()); + + // Matches exact + $_SERVER['HTTP_HOST'] = 'other.tld'; + $this->assertNull(Util::shouldRedirectDomain()); + + // Matches wildcard + $_SERVER['HTTP_HOST'] = 'sub.example.org'; + $this->assertNull(Util::shouldRedirectDomain()); + + // Disabled + Property::$values['webinterface.redirect-domain'] = 0; + $_SERVER['HTTP_HOST'] = 'some.other.domain.com'; + $this->assertNull(Util::shouldRedirectDomain()); + } + + public function testRandomBytesAndUuid(): void + { + $rb = Util::randomBytes(32, true); + $this->assertNotNull($rb); + $this->assertSame(32, strlen($rb)); + + // UUID v4 format: xxxxxxxx-xxxx-4xxx-[89ab]xxx-xxxxxxxxxxxx + for ($i = 0; $i < 10; $i++) { + $uuid = Util::randomUuid(); + $this->assertMatchesRegularExpression('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $uuid); + } + } + + public function testOsUptimeIfAvailable(): void + { + if (file_exists('/proc/uptime')) { + $this->assertGreaterThan(0, Util::osUptime()); + } else { + $this->markTestSkipped('No /proc/uptime on this platform'); + } + } +} diff --git a/tests/Modules/Exams/ExamsIncTest.php b/tests/Modules/Exams/ExamsIncTest.php new file mode 100644 index 00000000..1ad02f5f --- /dev/null +++ b/tests/Modules/Exams/ExamsIncTest.php @@ -0,0 +1,109 @@ +<?php + +use PHPUnit\Framework\TestCase; + +/** + * Tests for modules-available/exams/inc/exams.inc.php using the SQLite-backed DB. + * + */ +class ExamsIncTest extends TestCase +{ + protected function setUp(): void + { + Database::resetSchema(); + } + + private function loadInc(): void + { + require_once __DIR__ . '/../../../modules-available/exams/inc/exams.inc.php'; + } + + public function testIsInExamModeMatchesSpecificLocationAndGlobal(): void + { + $this->loadInc(); + $now = time(); + // Insert an exam active now, bound to location 2 + Database::exec('INSERT INTO exams (examid, lectureid, autologin, starttime, endtime, description) VALUES (1, :l, :a, :s, :e, :d)', [ + 'l' => 'L-1', + 'a' => 'auto1', + 's' => $now - 3600, + 'e' => $now + 3600, + 'd' => 'Exam in A' + ]); + Database::exec('INSERT INTO exams_x_location (examid, locationid) VALUES (1, 2)'); + + // No active exam window for location 3 then + $lecture = $auto = null; + $this->assertFalse(Exams::isInExamMode([3], $lecture, $auto)); + + // Insert a global exam (NULL location) + Database::exec('INSERT INTO exams (examid, lectureid, autologin, starttime, endtime, description) VALUES (2, :l, :a, :s, :e, :d)', [ + 'l' => 'L-2', + 'a' => 'auto2', + 's' => $now - 1800, + 'e' => $now + 1800, + 'd' => 'Global Exam' + ]); + Database::exec('INSERT INTO exams_x_location (examid, locationid) VALUES (2, NULL)'); + + $lecture = null; + $auto = null; + // Specific location match + $this->assertTrue(Exams::isInExamMode([2], $lecture, $auto)); + $this->assertSame('L-1', $lecture); + $this->assertSame('auto1', $auto); + + // Global match when no specific exam in list + $lecture = $auto = null; + $this->assertTrue(Exams::isInExamMode([999], $lecture, $auto)); + $this->assertSame('L-2', $lecture); + $this->assertSame('auto2', $auto); + } + + public function testPlausiblyInExamModeFiltersByDurationAndMachines(): void + { + $this->loadInc(); + $now = time(); + // Seed one ONLINE machine in location 2 + Database::exec("INSERT INTO machine (machineuuid, subnetlocationid, locationid, fixedlocationid, hostname, clientip, state) + VALUES ('ex1', 2, 2, 2, 'host-ex1', '10.0.0.20', 'ONLINE')"); + + // Exam too long (> 1 day) but lecture bounds should limit when lecture is active -> keep + Database::exec('INSERT INTO exams (examid, lectureid, autologin, starttime, endtime, description) VALUES (10, :l, :a, :s, :e, :d)', [ + 'l' => 'L-keep', + 's' => $now - 2 * 86400, + 'e' => $now + 2 * 86400, + 'd' => 'Long but bounded' + ]); + Database::exec('INSERT INTO exams_x_location (examid, locationid) VALUES (10, 2)'); + Database::exec('INSERT INTO sat_user (userid, firstname, lastname, email) VALUES (1, "A", "B", "a@b")'); + Database::exec('INSERT INTO sat_lecture (lectureid, ownerid, displayname, starttime, endtime, isexam, isenabled) + VALUES (:id, 1, :n, :ls, :le, 1, 1)', [ + 'id' => 'L-keep', + 'n' => 'Lecture Window', + 'ls' => $now - 300, + 'le' => $now + 300 + ]); + Database::exec('INSERT INTO sat_lecture_x_location (lectureid, locationid) VALUES (:id, 2)', ['id' => 'L-keep']); + + // Exam too long and zero machines -> should be skipped + Database::exec('INSERT INTO exams (examid, lectureid, autologin, starttime, endtime, description) VALUES (11, :l, :a, :s, :e, :d)', [ + 'l' => 'L-skip', + 's' => $now - 3 * 86400, + 'e' => $now + 3 * 86400, + 'd' => 'Long and no machines' + ]); + Database::exec('INSERT INTO exams_x_location (examid, locationid) VALUES (11, 5)'); + Database::exec('INSERT INTO sat_lecture (lectureid, ownerid, displayname, starttime, endtime, isexam, isenabled) VALUES (:id, 1, :n, :ls, :le, 1, 1)', [ + 'id' => 'L-skip', + 'n' => 'Lecture Long', + 'ls' => $now - 100000, + 'le' => $now + 100000 + ]); + + $out = Exams::plausiblyInExamMode(); + $names = array_column($out, 'examtitle'); + $this->assertContains('Long but bounded', $names); + $this->assertNotContains('Long and no machines', $names); + } +} diff --git a/tests/Modules/Exams/ExamsPageTest.php b/tests/Modules/Exams/ExamsPageTest.php new file mode 100644 index 00000000..f82e2452 --- /dev/null +++ b/tests/Modules/Exams/ExamsPageTest.php @@ -0,0 +1,102 @@ +<?php + +use PHPUnit\Framework\TestCase; + +/** + * Tests for modules-available/exams/page.inc.php using SQLite DB and stubs. + * + */ +class ExamsPageTest extends TestCase +{ + protected function setUp(): void + { + Database::resetSchema(); + Render::reset(); + Message::reset(); + User::reset(); + $_GET = $_POST = $_REQUEST = []; + // Use real Location + $GLOBALS['__TEST_USE_REAL_CLASSES'] = ['Location']; + require_once __DIR__ . '/../../../modules-available/locations/inc/location.inc.php'; + require_once __DIR__ . '/../../../modules-available/exams/inc/exams.inc.php'; + require_once __DIR__ . '/../../../modules-available/exams/page.inc.php'; + // Logged in + User::$loggedIn = true; + User::$id = 1; + } + + private function seedBasicExamAndLecture(int $locationId, string $examDesc = 'Exam 1'): void + { + $now = time(); + Database::exec('INSERT INTO exams (examid, lectureid, autologin, starttime, endtime, description) VALUES (100, :l, :a, :s, :e, :d)', [ + 'l' => 'LEC-1', 'a' => '', 's' => $now - 3600, 'e' => $now + 3600, 'd' => $examDesc + ]); + Database::exec('INSERT INTO exams_x_location (examid, locationid) VALUES (100, :lid)', ['lid' => $locationId]); + Database::exec('INSERT INTO sat_user (userid, firstname, lastname, email) VALUES (1, "T", "U", "t@u")'); + Database::exec('INSERT INTO sat_lecture (lectureid, ownerid, displayname, starttime, endtime, isexam, isenabled) VALUES (:id, 1, :n, :ls, :le, 1, 1)', [ + 'id' => 'LEC-1', 'n' => 'Lecture 1', 'ls' => $now - 7200, 'le' => $now + 7200 + ]); + Database::exec('INSERT INTO sat_lecture_x_location (lectureid, locationid) VALUES (:id, :lid)', ['id' => 'LEC-1', 'lid' => $locationId]); + } + + public function testShowRendersListsAndVisWithPermissions(): void + { + // Permissions: view on all, edit only for location 2 + User::$permissions = ['exams.view' => true, 'exams.edit' => true]; + User::$allowedLocations = [2]; + $this->seedBasicExamAndLecture(2, 'My Exam'); + + $_GET = ['action' => 'show']; + $_REQUEST = $_GET; + $page = new Page_Exams('exams'); + $page->preprocess(); + $page->render(); + + $names = array_column(Render::$templates, 'name'); + $this->assertContains('page-main-heading', $names); + $this->assertContains('page-exams', $names); + $this->assertContains('page-exams-vis', $names); + + // Find page-exams-vis payload and verify JSON fields decode + $vis = null; foreach (Render::$templates as $t) { if ($t['name'] === 'page-exams-vis') { $vis = $t; break; } } + $this->assertNotNull($vis); + $data = $vis['data']; + $items = json_decode($data['exams_json'], true); + $groups = json_decode($data['rooms_json'], true); + $this->assertIsArray($items); + $this->assertIsArray($groups); + $this->assertNotEmpty($items); + $this->assertNotEmpty($groups); + $groupIds = array_column($groups, 'id'); + $this->assertContains(2, $groupIds); + } + + public function testShowFiltersByViewLocationsAndDisablesEditWhenCannotEditAll(): void + { + // User can view location 2 but cannot edit location 3 + User::$permissions = ['exams.view' => true, 'exams.edit' => false]; + User::$allowedLocations = [2]; + $this->seedBasicExamAndLecture(3, 'Other Exam'); + + $_GET = ['action' => 'show']; + $_REQUEST = $_GET; + $page = new Page_Exams('exams'); + $page->preprocess(); + $page->render(); + + // page-exams rows payload should mark edit disabled when user lacks edit perms for locations + $tpl = null; + foreach (Render::$templates as $t) { + if ($t['name'] === 'page-exams') { + $tpl = $t; + break; + } + } + $this->assertNotNull($tpl); + $rows = $tpl['data']['exams'] ?? []; + if (!empty($rows)) { + $this->assertArrayHasKey('edit', $rows[0]); + $this->assertSame('disabled', $rows[0]['edit']['disabled'] ?? ''); + } + } +} diff --git a/tests/Modules/Locations/LocationTest.php b/tests/Modules/Locations/LocationTest.php new file mode 100644 index 00000000..86369691 --- /dev/null +++ b/tests/Modules/Locations/LocationTest.php @@ -0,0 +1,49 @@ +<?php + +namespace Locations; + +use Database; +use Location; +use PHPUnit\Framework\TestCase; + +/** + * Demonstrates using the SQLite-backed Database test backend to run code against + * real SQL tables without a MySQL server. This is analogous to DbUnit-style tests. + * + * We test the real Location helper here with real SQL data. + * + */ +class LocationWithSqliteTest extends TestCase +{ + protected function setUp(): void + { + // Load real Location class + require_once 'modules-available/locations/inc/location.inc.php'; + + // Fresh DB schema and canonical seed for this test + Database::resetSchema(); + } + + public function testTreeAndAssocAndMapping(): void + { + $tree = Location::getTree(); + $this->assertSame(['Campus', 'Offsite'], array_column($tree, 'locationname')); + $assoc = Location::getLocationsAssoc(); + $this->assertTrue($assoc[4]['isleaf']); + $this->assertSame([1, 2], $assoc[4]['parents']); + + // Mapping respects depth and subnet size + $this->assertSame(2, Location::mapIpToLocation('10.0.0.150')); + $this->assertSame(1, Location::mapIpToLocation('10.0.0.50')); + $this->assertSame(5, Location::mapIpToLocation('192.168.1.22')); + } + + public function testUpdateMapIpToLocationIssuesUpdateSql(): void + { + $ret = Location::updateMapIpToLocation('uuid-1', '10.0.0.5'); + $this->assertSame(1, $ret); + + $row = Database::queryFirst('SELECT subnetlocationid FROM machine WHERE machineuuid = :uuid', ['uuid' => 'uuid-1']); + $this->assertSame(1, (int)$row['subnetlocationid']); + } +} diff --git a/tests/Modules/Locations/LocationUtilTest.php b/tests/Modules/Locations/LocationUtilTest.php new file mode 100644 index 00000000..6c91e0d2 --- /dev/null +++ b/tests/Modules/Locations/LocationUtilTest.php @@ -0,0 +1,106 @@ +<?php + +use PHPUnit\Framework\TestCase; + +/** + * Tests for modules-available/locations/inc/locationutil.inc.php using SQLite DB and real Location. + * + */ +class LocationUtilTest extends TestCase +{ + protected function setUp(): void + { + Database::resetSchema(); + Message::reset(); + $GLOBALS['__TEST_USE_REAL_CLASSES'] = ['Location']; + require_once __DIR__ . '/../../../modules-available/locations/inc/location.inc.php'; + require_once __DIR__ . '/../../../modules-available/locations/inc/locationutil.inc.php'; + } + + public function testRangeToLongAndVerboseValidation(): void + { + list($s, $e) = LocationUtil::rangeToLong('10.0.0.1', '10.0.0.9'); + $this->assertIsInt($s); + $this->assertIsInt($e); + $this->assertLessThan($s + 100, $e); + + // Invalid addresses -> verbose returns null and records warnings + Message::reset(); + $this->assertNull(LocationUtil::rangeToLongVerbose('bad-ip', '10.0.0.1')); + $this->assertContains('main.value-invalid', Message::$warnings); + Message::reset(); + $this->assertNull(LocationUtil::rangeToLongVerbose('10.0.0.9', '10.0.0.1')); + $this->assertContains('main.value-invalid', Message::$warnings); + } + + public function testGetOverlappingSubnetsSelfAndOther(): void + { + // Sanity: 2 and 3 are siblings under 1 in the canonical seed + $assoc = Location::getLocationsAssoc(); + $this->assertSame(1, $assoc[2]['parentlocationid']); + $this->assertSame(1, $assoc[3]['parentlocationid']); + $this->assertNotContains(2, $assoc[3]['parents']); + $this->assertNotContains(3, $assoc[2]['parents']); + + // Self-overlap in location 5: add overlapping ranges + $startA = sprintf('%u', ip2long('192.168.1.0')); + $endA = sprintf('%u', ip2long('192.168.1.127')); + $startB = sprintf('%u', ip2long('192.168.1.64')); + $endB = sprintf('%u', ip2long('192.168.1.200')); + Database::exec('INSERT INTO subnet (startaddr, endaddr, locationid) VALUES (:s, :e, 5)', ['s' => $startA, 'e' => $endA]); + Database::exec('INSERT INTO subnet (startaddr, endaddr, locationid) VALUES (:s, :e, 5)', ['s' => $startB, 'e' => $endB]); + + // Other-overlap between unrelated siblings 2 and 3: make 3 overlap 2's 10.0.0.128/26 by adding a + // wider 10.0.0.0/24 in location 3. + $startC = sprintf('%u', ip2long('10.0.0.0')); + $endC = sprintf('%u', ip2long('10.0.0.255')); + Database::exec('INSERT INTO subnet (startaddr, endaddr, locationid) VALUES (:s, :e, 3)', ['s' => $startC, 'e' => $endC]); + + $overSelf = []; + $overOther = []; + LocationUtil::getOverlappingSubnets($overSelf, $overOther); + + // Self: expect one entry with location name 'Offsite' (id 5 in seed) + $this->assertNotEmpty($overSelf); + $names = array_column($overSelf, 'locationname'); + $this->assertContains('Offsite', $names); + + // Other: expect a pair 2|3 (order not guaranteed, check that both names present) + $this->assertNotEmpty($overOther); + $pairFound = false; + foreach ($overOther as $p) { + if ((isset($p['name1']) && isset($p['name2']))) { + $pair = [$p['name1'], $p['name2']]; sort($pair); + if ($pair === ['Building A', 'Building B']) { $pairFound = true; break; } + } + } + $this->assertTrue($pairFound, 'Expected overlap between Building A and Building B'); + } + + public function testGetMachinesWithLocationMismatchSummaryAndDetail(): void + { + // Summary across all locations + $summary = LocationUtil::getMachinesWithLocationMismatch(0, false); + // Expect entries for fixedlocationid 5 (m1 mismatch) and 2 (wrong2) + $byId = []; + foreach ($summary as $row) { $byId[$row['locationid']] = $row; } + $this->assertArrayHasKey(5, $byId); + $this->assertArrayHasKey(2, $byId); + $this->assertSame('Offsite', $byId[5]['locationname']); + $this->assertSame('Building A', $byId[2]['locationname']); + $this->assertGreaterThanOrEqual(1, $byId[5]['count']); + $this->assertGreaterThanOrEqual(1, $byId[2]['count']); + + // Detail for location 5 + $detail = LocationUtil::getMachinesWithLocationMismatch(5, false); + $this->assertSame(5, $detail['locationid']); + $this->assertSame('Offsite', $detail['locationname']); + $this->assertNotEmpty($detail['clients']); + $client = $detail['clients'][0]; + $this->assertArrayHasKey('machineuuid', $client); + $this->assertArrayHasKey('iplocationid', $client); + $this->assertSame('Campus', $client['iplocationname']); // iplocationid 1 from seed + $this->assertFalse($client['ipisleaf']); // root has children + $this->assertFalse($client['canmove']); // not leaf -> cannot move + } +} diff --git a/tests/Modules/Locations/OpeningTimesTest.php b/tests/Modules/Locations/OpeningTimesTest.php new file mode 100644 index 00000000..094d8687 --- /dev/null +++ b/tests/Modules/Locations/OpeningTimesTest.php @@ -0,0 +1,56 @@ +<?php + +use PHPUnit\Framework\TestCase; + +/** + * Tests for modules-available/locations/inc/openingtimes.inc.php using the SQLite-backed DB + * and the real Location class. + * + */ +class OpeningTimesTest extends TestCase +{ + protected function setUp(): void + { + Database::resetSchema(); + Message::reset(); + // Use the real Location implementation + $GLOBALS['__TEST_USE_REAL_CLASSES'] = ['Location']; + require_once __DIR__ . '/../../../modules-available/locations/inc/location.inc.php'; + require_once __DIR__ . '/../../../modules-available/locations/inc/openingtimes.inc.php'; + } + + public function testForLocationReturnsNearestAncestorOpeningTimes(): void + { + // Seed: location 1 has all-day opening time from Database::seedData; ensure child (2) has no specific times + $times1 = OpeningTimes::forLocation(1); + $this->assertIsArray($times1); + $this->assertNotEmpty($times1); + $this->assertSame('00:00', $times1[0]['openingtime']); + $this->assertSame('23:59', $times1[0]['closingtime']); + + // For location 2 (child of 1), it should inherit from 1 + $times2 = OpeningTimes::forLocation(2); + $this->assertIsArray($times2); + $this->assertSame($times1, $times2); + } + + public function testIsRoomOpenTrueWhenNoOpeningTimes(): void + { + // Remove opening times for all locations to simulate no configuration + Database::exec('UPDATE location SET openingtime = NULL'); + $this->assertTrue(OpeningTimes::isRoomOpen(3)); + } + + public function testIsRoomOpenRespectsOffsets(): void + { + // Without offset, should be closed (opening time is in the future) + $this->assertFalse(OpeningTimes::isRoomOpen(5)); + // With openOffsetMin large enough to shift opening time earlier, it should be open + $this->assertTrue(OpeningTimes::isRoomOpen(5, 90)); + + // Without offset, should be closed (opening time is in the future) + $this->assertFalse(OpeningTimes::isRoomOpen(4)); + // With closeOffsetMin large enough to shift closing time later, it should be open + $this->assertTrue(OpeningTimes::isRoomOpen(4, 0, 90)); + } +} diff --git a/tests/Modules/PermissionManager/GetPermissionDataTest.php b/tests/Modules/PermissionManager/GetPermissionDataTest.php new file mode 100644 index 00000000..98e4519c --- /dev/null +++ b/tests/Modules/PermissionManager/GetPermissionDataTest.php @@ -0,0 +1,62 @@ +<?php + +use PHPUnit\Framework\TestCase; + +/** + * Adapted to use the SQLite-backed Database test backend with the canonical schema/seed. + * We also use the real Location class as GetPermissionData relies on it. + * + */ +class GetPermissionDataTest extends TestCase +{ + protected function setUp(): void + { + Database::resetSchema(); + // Use real Location + $GLOBALS['__TEST_USE_REAL_CLASSES'] = ['Location']; + require_once __DIR__ . '/../../../modules-available/locations/inc/location.inc.php'; + } + + public function testGetUserDataAggregatesRolesPerUser(): void + { + require_once __DIR__ . '/../../../modules-available/permissionmanager/inc/getpermissiondata.inc.php'; + $out = GetPermissionData::getUserData(); + // Normalize by userid for stable asserts + usort($out, function ($a, $b) { return (int)$a['userid'] <=> (int)$b['userid']; }); + $this->assertCount(2, $out); + $u1 = $out[0]; + $this->assertSame(1, (int)$u1['userid']); + $this->assertSame('alice', $u1['username']); + $this->assertGreaterThanOrEqual(1, count($u1['roles'])); + // Bob exists too + $u2 = $out[1]; + $this->assertSame(2, (int)$u2['userid']); + $this->assertSame('bob', $u2['username']); + } + + public function testGetLocationDataExpandsChildrenAndGlobal(): void + { + require_once __DIR__ . '/../../../modules-available/permissionmanager/inc/getpermissiondata.inc.php'; + $out = GetPermissionData::getLocationData(); + $ids = array_column($out, 'locationid'); + $this->assertContains(0, $ids); // global + $this->assertContains(2, $ids); // Building A + $this->assertContains(4, $ids); // Room 101 (child) + $byId = []; + foreach ($out as $row) { $byId[$row['locationid']] = $row; } + $this->assertNotEmpty($byId[0]['roles']); + $this->assertSame('Admin', $byId[0]['roles'][0]['rolename']); + $this->assertSame('Tech', $byId[2]['roles'][0]['rolename']); + $this->assertSame('Tech', $byId[4]['roles'][0]['rolename']); + } + + public function testGetRolesPassThroughAndSorting(): void + { + require_once __DIR__ . '/../../../modules-available/permissionmanager/inc/getpermissiondata.inc.php'; + $out = GetPermissionData::getRoles(GetPermissionData::WITH_USER_COUNT | GetPermissionData::WITH_LOCATION_COUNT); + // Seed contains Admin and Tech, sorted alphabetically + $this->assertGreaterThanOrEqual(2, count($out)); + $this->assertSame('Admin', $out[0]['rolename']); + $this->assertSame('Tech', $out[1]['rolename']); + } +} diff --git a/tests/Modules/PermissionManager/PermissionDbUpdateTest.php b/tests/Modules/PermissionManager/PermissionDbUpdateTest.php new file mode 100644 index 00000000..7eff16ad --- /dev/null +++ b/tests/Modules/PermissionManager/PermissionDbUpdateTest.php @@ -0,0 +1,90 @@ +<?php + +use PHPUnit\Framework\TestCase; + +/** + * Migrated to SQLite-backed Database. We verify actual DB state instead of stub call logs. + * + */ +class PermissionDbUpdateTest extends TestCase +{ + protected function setUp(): void + { + Database::resetSchema(); + } + + private function loadInc(): void + { + require_once __DIR__ . '/../../../modules-available/permissionmanager/inc/permissiondbupdate.inc.php'; + } + + public function testAddAndRemoveRoleFromUser(): void + { + $this->loadInc(); + // Ensure target roles exist + Database::exec('INSERT INTO role (roleid, rolename) VALUES (:id, :n)', ['id' => 10, 'n' => 'R10']); + Database::exec('INSERT INTO role (roleid, rolename) VALUES (:id, :n)', ['id' => 11, 'n' => 'R11']); + // add + PermissionDbUpdate::addRoleToUser([1,2], [10]); + $rows = Database::queryAll('SELECT * FROM role_x_user WHERE roleid = :r ORDER BY userid, roleid', ['r' => 10]); + $this->assertSame([[ 'userid' => 1, 'roleid' => 10 ], [ 'userid' => 2, 'roleid' => 10 ]], $rows); + + // remove + PermissionDbUpdate::removeRoleFromUser([1,2], [10,11]); + $rows2 = Database::queryAll('SELECT * FROM role_x_user WHERE roleid IN (:r)', ['r' => [10,11]]); + $this->assertCount(0, $rows2); + } + + public function testSetRolesForUserDeletesThenAdds(): void + { + $this->loadInc(); + Database::exec('INSERT INTO role (roleid, rolename) VALUES (:id, :n)', ['id' => 10, 'n' => 'R10']); + Database::exec('INSERT INTO role (roleid, rolename) VALUES (:id, :n)', ['id' => 11, 'n' => 'R11']); + // Prepopulate with an extra role to be removed + Database::exec('INSERT INTO role_x_user (userid, roleid) VALUES (5, 12)'); + PermissionDbUpdate::setRolesForUser([5], [10, 11]); + $rows = Database::queryAll('SELECT roleid FROM role_x_user WHERE userid = 5 ORDER BY roleid'); + $this->assertSame([[ 'roleid' => 10 ], [ 'roleid' => 11 ]], $rows); + } + + public function testSaveRoleUpdateAndInsertPaths(): void + { + $this->loadInc(); + // Create existing role 42 with some mappings + Database::exec('INSERT INTO role (roleid, rolename, roledescription) VALUES (:id, :n, :d)', ['id' => 42, 'n' => 'Old', 'd' => 'old']); + Database::exec('INSERT INTO role_x_location (roleid, locationid) VALUES (:r, :l)', ['r' => 42, 'l' => 1]); + Database::exec('INSERT INTO role_x_permission (roleid, permissionid) VALUES (:r, :p)', ['r' => 42, 'p' => 'permissionmanager.users.view']); + // Update existing role + PermissionDbUpdate::saveRole('Editors', 'desc', [0, 5], ['permissionmanager.roles.edit'], 42); + $row = Database::queryFirst('SELECT rolename, roledescription FROM role WHERE roleid = 42'); + $this->assertSame('Editors', $row['rolename']); + $this->assertSame('desc', $row['roledescription']); + $locs = Database::queryAll('SELECT locationid FROM role_x_location WHERE roleid = 42 ORDER BY locationid'); + $this->assertSame([[ 'locationid' => 0 ], [ 'locationid' => 5 ]], $locs); + $perms = Database::queryAll('SELECT permissionid FROM role_x_permission WHERE roleid = 42'); + $this->assertSame([[ 'permissionid' => 'permissionmanager.roles.edit' ]], $perms); + + // Insert new role with mappings + $this->loadInc(); + PermissionDbUpdate::saveRole('New Role', 'desc2', [1], ['permissionmanager.users.view']); + // Fetch the last inserted role + $role = Database::queryFirst('SELECT roleid, rolename, roledescription FROM role WHERE rolename = :n', ['n' => 'New Role']); + $this->assertNotFalse($role); + $rid = (int)$role['roleid']; + $this->assertGreaterThan(0, $rid); + $this->assertSame('desc2', $role['roledescription']); + $locs2 = Database::queryAll('SELECT locationid FROM role_x_location WHERE roleid = :r', ['r' => $rid]); + $this->assertSame([[ 'locationid' => 1 ]], $locs2); + $perms2 = Database::queryAll('SELECT permissionid FROM role_x_permission WHERE roleid = :r', ['r' => $rid]); + $this->assertSame([[ 'permissionid' => 'permissionmanager.users.view' ]], $perms2); + } + + public function testDeleteRole(): void + { + $this->loadInc(); + Database::exec('INSERT INTO role (roleid, rolename) VALUES (:id, :n)', ['id' => 77, 'n' => 'Tmp']); + PermissionDbUpdate::deleteRole(77); + $row = Database::queryFirst('SELECT * FROM role WHERE roleid = 77'); + $this->assertFalse($row); + } +} diff --git a/tests/Modules/PermissionManager/PermissionManagerPageTest.php b/tests/Modules/PermissionManager/PermissionManagerPageTest.php new file mode 100644 index 00000000..835e2484 --- /dev/null +++ b/tests/Modules/PermissionManager/PermissionManagerPageTest.php @@ -0,0 +1,75 @@ +<?php + +use PHPUnit\Framework\TestCase; + +/** + * Adapted to use the SQLite-backed Database test backend and real Location class. + * + */ +class PermissionManagerPageTest extends TestCase +{ + protected function setUp(): void + { + Database::resetSchema(); + Render::reset(); + User::reset(); + $_GET = $_POST = $_REQUEST = $_SERVER = []; + // Logged in user id (not used directly by this module flow) + User::$loggedIn = true; + User::$id = 123; + $_SERVER['REQUEST_METHOD'] = 'GET'; + // Use real Location implementation for helper functions used by the module + $GLOBALS['__TEST_USE_REAL_CLASSES'] = ['Location']; + require_once 'modules-available/locations/inc/location.inc.php'; + } + + private function newPage(): Page_PermissionManager + { + require_once 'modules-available/permissionmanager/inc/getpermissiondata.inc.php'; + require_once 'modules-available/permissionmanager/page.inc.php'; + return new Page_PermissionManager('permissionmanager'); + } + + public function testRenderRolesTable(): void + { + User::$permissions = ['roles.*' => true]; + $_GET['show'] = 'roles'; + $_REQUEST = $_GET; + + $page = $this->newPage(); + $page->preprocess(); + $page->render(); + + $names = array_column(Render::$templates, 'name'); + $this->assertContains('rolestable', $names); + } + + public function testRenderUsersTableWithAllRoles(): void + { + User::$permissions = ['users.*' => true, 'users.edit-roles' => true]; + $_GET['show'] = 'users'; + $_REQUEST = $_GET; + + $page = $this->newPage(); + $page->preprocess(); + $page->render(); + + $names = array_column(Render::$templates, 'name'); + $this->assertContains('role-filter-selectize', $names); + $this->assertContains('userstable', $names); + } + + public function testRenderLocationsTable(): void + { + User::$permissions = ['locations.*' => true]; + $_GET['show'] = 'locations'; + $_REQUEST = $_GET; + + $page = $this->newPage(); + $page->preprocess(); + $page->render(); + + $names = array_column(Render::$templates, 'name'); + $this->assertContains('locationstable', $names); + } +} diff --git a/tests/Modules/PermissionManager/PermissionUtilTest.php b/tests/Modules/PermissionManager/PermissionUtilTest.php new file mode 100644 index 00000000..2452d7dc --- /dev/null +++ b/tests/Modules/PermissionManager/PermissionUtilTest.php @@ -0,0 +1,85 @@ +<?php + +use PHPUnit\Framework\TestCase; + +/** + * PermissionUtil tests adapted to the SQLite-backed Database backend and real Location class. + * + */ +class PermissionUtilTest extends TestCase +{ + protected function setUp(): void + { + Database::resetSchema(); + User::reset(); + $_GET = $_POST = $_REQUEST = $_SERVER = []; + // Use real Location implementation + $GLOBALS['__TEST_USE_REAL_CLASSES'] = ['Location']; + require_once __DIR__ . '/../../../modules-available/locations/inc/location.inc.php'; + } + + public function testUserHasPermissionAnyLocationWithWildcardMatch(): void + { + User::$id = 2; // not special admin + require_once __DIR__ . '/../../../modules-available/permissionmanager/inc/permissionutil.inc.php'; + // Create a role that grants permissionmanager.roles.* and assign it to user 2 + Database::exec('INSERT INTO role (roleid, rolename, builtin, roledescription) VALUES (:id, :n, 0, :d)', ['id' => 3, 'n' => 'Roles', 'd' => '']); + Database::exec('INSERT INTO role_x_user (userid, roleid) VALUES (:u, :r)', ['u' => 2, 'r' => 3]); + Database::exec('INSERT INTO role_x_permission (roleid, permissionid) VALUES (:r, :p)', ['r' => 3, 'p' => 'permissionmanager.roles.*']); + // Also add one row in role_x_location to satisfy INNER JOIN in location-specific queries; for ANY (null) use NULL + Database::exec('INSERT INTO role_x_location (roleid, locationid) VALUES (:r, NULL)', ['r' => 3]); + $this->assertTrue(PermissionUtil::userHasPermission(2, 'permissionmanager.roles.*', null)); + // cached second call should also return true + $this->assertTrue(PermissionUtil::userHasPermission(2, 'permissionmanager.roles.*', null)); + } + + public function testUserHasPermissionLocationSpecificWithParentInheritance(): void + { + User::$id = 3; + require_once __DIR__ . '/../../../modules-available/permissionmanager/inc/permissionutil.inc.php'; + // Seed: create role 4 granting users.view at location 2 (parent of 4) + Database::exec('INSERT INTO role (roleid, rolename, builtin, roledescription) VALUES (:id, :n, 0, :d)', ['id' => 4, 'n' => 'Users', 'd' => '']); + Database::exec('INSERT INTO role_x_user (userid, roleid) VALUES (:u, :r)', ['u' => 3, 'r' => 4]); + Database::exec('INSERT INTO role_x_permission (roleid, permissionid) VALUES (:r, :p)', ['r' => 4, 'p' => 'permissionmanager.users.view']); + Database::exec('INSERT INTO role_x_location (roleid, locationid) VALUES (:r, :l)', ['r' => 4, 'l' => 2]); + // userHasPermission should return true for location 4 by inheritance (2 is parent of 4 in seed) + $this->assertTrue(PermissionUtil::userHasPermission(3, 'permissionmanager.users.view', 4)); + // And false for unrelated location (3) + $this->assertFalse(PermissionUtil::userHasPermission(3, 'permissionmanager.users.view', 3)); + } + + public function testAdminBypassForPermissionManager(): void + { + // userid 1 always allowed for permissionmanager.* + User::$id = 1; + require_once __DIR__ . '/../../../modules-available/permissionmanager/inc/permissionutil.inc.php'; + $this->assertTrue(PermissionUtil::userHasPermission(1, 'permissionmanager.*', null)); + $this->assertTrue(PermissionUtil::userHasPermission(1, 'permissionmanager.roles.edit', 5)); + } + + public function testGetAllowedLocationsGlobalAndSpecific(): void + { + User::$id = 5; + require_once __DIR__ . '/../../../modules-available/permissionmanager/inc/permissionutil.inc.php'; + // Case 1: global permission (locationid NULL -> treated as 0 and expands to all) + Database::exec('INSERT INTO role (roleid, rolename, builtin, roledescription) VALUES (:id, :n, 0, :d)', ['id' => 5, 'n' => 'Global', 'd' => '']); + Database::exec('INSERT INTO role_x_user (userid, roleid) VALUES (:u, :r)', ['u' => 5, 'r' => 5]); + Database::exec('INSERT INTO role_x_permission (roleid, permissionid) VALUES (:r, :p)', ['r' => 5, 'p' => 'permissionmanager.users.view']); + Database::exec('INSERT INTO role_x_location (roleid, locationid) VALUES (:r, NULL)', ['r' => 5]); + $locs = PermissionUtil::getAllowedLocations(5, 'permissionmanager.users.view'); + $this->assertContains(0, $locs); + $this->assertContains(1, $locs); + $this->assertContains(2, $locs); + $this->assertContains(4, $locs); + + // Case 2: specific base location expands with children (location 2 -> includes 4) + Database::exec('INSERT INTO role (roleid, rolename, builtin, roledescription) VALUES (:id, :n, 0, :d)', ['id' => 6, 'n' => 'Loc', 'd' => '']); + Database::exec('INSERT INTO role_x_user (userid, roleid) VALUES (:u, :r)', ['u' => 5, 'r' => 6]); + Database::exec('INSERT INTO role_x_permission (roleid, permissionid) VALUES (:r, :p)', ['r' => 6, 'p' => 'permissionmanager.roles.*']); + Database::exec('INSERT INTO role_x_location (roleid, locationid) VALUES (:r, :l)', ['r' => 6, 'l' => 2]); + $locs2 = PermissionUtil::getAllowedLocations(5, 'permissionmanager.roles.edit'); + $this->assertNotContains(0, $locs2); + $this->assertContains(2, $locs2); + $this->assertContains(4, $locs2); + } +} diff --git a/tests/Modules/RebootControl/RebootControlIncTest.php b/tests/Modules/RebootControl/RebootControlIncTest.php new file mode 100644 index 00000000..bb70aea7 --- /dev/null +++ b/tests/Modules/RebootControl/RebootControlIncTest.php @@ -0,0 +1,155 @@ +<?php + +use PHPUnit\Framework\TestCase; + +/** + * Tests for modules-available/rebootcontrol/inc/rebootcontrol.inc.php + * Uses SQLite-backed Database where needed and shared stubs for Taskmanager etc. + * + */ +class RebootControlIncTest extends TestCase +{ + protected function setUp(): void + { + Database::resetSchema(); + Taskmanager::reset(); + TaskmanagerCallback::reset(); + EventLog::reset(); + Property::reset(); + $_GET = $_POST = $_REQUEST = []; + + require_once __DIR__ . '/../../../modules-available/rebootcontrol/inc/rebootutils.inc.php'; + require_once __DIR__ . '/../../../modules-available/rebootcontrol/inc/sshkey.inc.php'; + + // Load SUT + require_once __DIR__ . '/../../../modules-available/rebootcontrol/inc/rebootcontrol.inc.php'; + } + + public function testExecuteAddsTaskAndAppliesEventFilter(): void + { + // Arrange: two clients + $list = [ + ['machineuuid' => 'u1', 'clientip' => '10.0.0.10', 'locationid' => 2], + ['machineuuid' => 'u2', 'clientip' => '10.0.0.11', 'locationid' => 2], + ]; + + $task = RebootControl::execute($list, RebootControl::REBOOT, 0); + $this->assertIsArray($task); + $this->assertArrayHasKey('id', $task); + + // Property list should contain a JSON object with our task id and clients + $listEntries = Property::getList(RebootControl::KEY_TASKLIST); + $this->assertNotEmpty($listEntries); + $found = false; + foreach ($listEntries as $entry) { + $p = json_decode($entry, true); + if (is_array($p) && ($p['id'] ?? '') === $task['id']) { + $found = true; + $this->assertSame(RebootControl::TASK_REBOOTCTL, $p['type']); + $this->assertSame([2], $p['locations']); + break; + } + } + $this->assertTrue($found, 'Expected task to be recorded via Property::addToList'); + + // EventLog::applyFilterRules should have been called for each client + $this->assertCount(2, EventLog::$applied); + $this->assertSame('#action-power', EventLog::$applied[0]['category']); + } + + public function testRebootDelegatesToExecuteUsingRebootUtils(): void + { + $task = RebootControl::reboot(['m2'], false); + $this->assertIsArray($task); + $this->assertArrayHasKey('id', $task); + // Ensure Taskmanager recorded a RemoteReboot submission + $this->assertNotEmpty(Taskmanager::$submissions); + $this->assertSame('RemoteReboot', Taskmanager::$submissions[0]['task']); + } + + public function testRunScriptResolvesUuidToClientAndSubmitsRemoteExec(): void + { + // Seed a machine in DB (no extra tables needed) + Database::exec("INSERT INTO machine (machineuuid, subnetlocationid, locationid, hostname, clientip, state) + VALUES (:u, 1, 1, 'h', '10.0.0.55', 'IDLE')", ['u' => 'RES-1']); + $clients = [ + ['machineuuid' => 'RES-1'], // missing clientip -> should be looked up + ['clientip' => '192.168.1.22'], + ]; + $task = RebootControl::runScript($clients, 'echo hi', 3, 'KEY'); + $this->assertIsArray($task); + $this->assertArrayHasKey('id', $task); + // Verify Taskmanager submission name and arguments + $this->assertNotEmpty(Taskmanager::$submissions); + $sub = end(Taskmanager::$submissions); + $this->assertSame('RemoteExec', $sub['task']); + $this->assertArrayHasKey('clients', $sub['data']); + $optClients = $sub['data']['clients']; + $this->assertContains('10.0.0.55', array_column($optClients, 'clientip')); + } + + public function testWakeDirectlySubmitsDirectClients(): void + { + $task = RebootControl::wakeDirectly(['aa:bb:cc:dd:ee:ff', '11:22:33:44:55:66'], '10.0.0.255', null); + $this->assertIsArray($task); + $sub = end(Taskmanager::$submissions); + $this->assertSame('WakeOnLan', $sub['task']); + $this->assertArrayHasKey('clients', $sub['data']); + $this->assertCount(2, $sub['data']['clients']); + $this->assertSame('DIRECT', $sub['data']['clients'][0]['methods'][0]); + } + + public function testWakeViaClientBuildsJawolLoop(): void + { + $clients = [['clientip' => '10.0.0.50']]; + $task = RebootControl::wakeViaClient($clients, 'aa-bb-cc-dd-ee-ff', '10.0.0.255', 'ffff'); + $this->assertIsArray($task); + $sub = end(Taskmanager::$submissions); + $this->assertSame('RemoteExec', $sub['task']); + $cmd = $sub['data']['command'] ?? ''; + $this->assertStringContainsString('jawol', $cmd); + $this->assertStringContainsString("-d '10.0.0.255'", $cmd); + $this->assertStringContainsString("-p 'ffff'", $cmd); + } + + public function testWakeViaJumpHostAddsCallback(): void + { + $jh = ['hostid' => 7, + 'host' => '10.0.0.77', + 'port' => 9922, + 'username' => 'root', + 'sshkey' => 'KEY', + 'script' => 'echo wake %MACS% %IP%']; + $task = RebootControl::wakeViaJumpHost($jh, '10.0.0.255', [['macaddr' => 'aa:bb:cc:dd:ee:ff']]); + $this->assertIsArray($task); + $this->assertNotEmpty(TaskmanagerCallback::$callbacks); + $cb = end(TaskmanagerCallback::$callbacks); + $this->assertSame('rbcConnCheck', $cb['name']); + $this->assertSame(7, $cb['arg']); + $sub = end(Taskmanager::$submissions); + $this->assertSame('RemoteExec', $sub['task']); + $cmd = $sub['data']['command'] ?? ''; + $this->assertStringContainsString('aa:bb:cc:dd:ee:ff', $cmd); + } + + public function testGetActiveTasksFiltersByLocationsAndId(): void + { + // Create two dummy task entries + $entry1 = json_encode(['id' => 'A', 'locations' => [1, 2], 'tasks' => ['t1']]); + $entry2 = json_encode(['id' => 'B', 'locations' => [5], 'tasks' => ['t2']]); + Property::addToList(RebootControl::KEY_TASKLIST, $entry1, 20); + Property::addToList(RebootControl::KEY_TASKLIST, $entry2, 20); + // Mark t1 as valid and t2 as invalid + Taskmanager::$statusById['t1'] = ['id' => 't1', 'statusCode' => 'RUNNING']; + Taskmanager::$statusById['t2'] = ['id' => 't2', 'statusCode' => 'UNKNOWN']; // treated as invalid by isTask() + + // Filter by locations + $out = RebootControl::getActiveTasks([1], null); + $this->assertNotEmpty($out); + $this->assertSame('A', $out[0]['id']); + // Query by id returns the item + $one = RebootControl::getActiveTasks(null, 'A'); + $this->assertIsArray($one); + $this->assertSame('A', $one['id']); + } +} diff --git a/tests/Modules/RebootControl/RebootUtilsTest.php b/tests/Modules/RebootControl/RebootUtilsTest.php new file mode 100644 index 00000000..fe347497 --- /dev/null +++ b/tests/Modules/RebootControl/RebootUtilsTest.php @@ -0,0 +1,76 @@ +<?php + +use PHPUnit\Framework\TestCase; + +/** + * Tests for modules-available/rebootcontrol/inc/rebootutils.inc.php using the SQLite-backed DB. + * + */ +class RebootUtilsTest extends TestCase +{ + protected function setUp(): void + { + Database::resetSchema(); + Message::reset(); + User::reset(); + $_GET = $_POST = $_REQUEST = []; + require_once __DIR__ . '/../../../modules-available/rebootcontrol/inc/rebootutils.inc.php'; + } + + public function testGetMachinesByUuidAssocAndFlat(): void + { + // Seed a couple of machines + Database::exec("INSERT INTO machine (machineuuid, subnetlocationid, locationid, hostname, clientip, state) + VALUES ('RU-1', 1, 2, 'h1', '10.0.0.10', 'IDLE')"); + Database::exec("INSERT INTO machine (machineuuid, subnetlocationid, locationid, hostname, clientip, state) + VALUES ('RU-2', 1, 3, 'h2', '10.0.0.11', 'OFFLINE')"); + + $flat = RebootUtils::getMachinesByUuid(['RU-1', 'RU-2']); + $this->assertCount(2, $flat); + $this->assertEqualsCanonicalizing(['RU-2', 'RU-1'], array_column($flat, 'machineuuid')); + + $assoc = RebootUtils::getMachinesByUuid(['RU-1', 'RU-2'], true); + $this->assertArrayHasKey('RU-1', $assoc); + $this->assertSame('h1', $assoc['RU-1']['hostname']); + } + + public function testSortRunningFirstOrdersIdleAndOccupiedFirst(): void + { + $arr = [ + ['state' => 'OFFLINE', 'x' => 3], + ['state' => 'OCCUPIED', 'x' => 2], + ['state' => 'IDLE', 'x' => 1], + ]; + RebootUtils::sortRunningFirst($arr); + $this->assertContains($arr[0]['state'], ['IDLE', 'OCCUPIED']); + $this->assertContains($arr[1]['state'], ['IDLE', 'OCCUPIED']); + $this->assertSame('OFFLINE', $arr[2]['state']); + } + + public function testGetFilteredMachineListFiltersByPermissionAndWarnings(): void + { + // Seed machines + Database::exec("INSERT INTO machine (machineuuid, subnetlocationid, locationid, hostname, clientip, state) + VALUES ('RU-3', 1, 2, 'h3', '10.0.0.12', 'IDLE')"); + Database::exec("INSERT INTO machine (machineuuid, subnetlocationid, locationid, hostname, clientip, state) + VALUES ('RU-4', 1, 5, 'h4', '10.0.0.13', 'IDLE')"); + + // No permissions -> both filtered out + User::$loggedIn = true; + User::$permissions = []; // User::hasPermission returns false + $out = RebootUtils::getFilteredMachineList(['RU-3', 'RU-4'], '.rebootcontrol.action.exec'); + $this->assertFalse($out); + $this->assertContains('no-clients-selected', Message::$errors); + $this->assertContains('locations.no-permission-location', Message::$warnings); + + // Allow only location 2 via hasPermission -> return only RU-3 + Message::reset(); + User::$permissions = ['.rebootcontrol.action.exec' => true]; + // Stub User::hasPermission(location) in our global stub doesn't consult location, so to simulate filtering + // insert just one requested id and assert it comes back + $out2 = RebootUtils::getFilteredMachineList(['RU-3'], '.rebootcontrol.action.exec'); + $this->assertIsArray($out2); + $this->assertCount(1, $out2); + $this->assertSame('RU-3', $out2[0]['machineuuid']); + } +} diff --git a/tests/Modules/RebootControl/SSHKeyTest.php b/tests/Modules/RebootControl/SSHKeyTest.php new file mode 100644 index 00000000..5cec9ac4 --- /dev/null +++ b/tests/Modules/RebootControl/SSHKeyTest.php @@ -0,0 +1,45 @@ +<?php + +use PHPUnit\Framework\TestCase; + +/** + * Tests for modules-available/rebootcontrol/inc/sshkey.inc.php + * + */ +class SSHKeyTest extends TestCase +{ + protected function setUp(): void + { + Database::resetSchema(); // not strictly needed here + Property::reset(); + $_GET = $_POST = $_REQUEST = []; + require_once __DIR__ . '/../../../modules-available/rebootcontrol/inc/sshkey.inc.php'; + } + + public function testGetPrivateKeyUsesExistingAndDoesNotRegen(): void + { + $existing = "-----BEGIN PRIVATE KEY-----\nTESTKEY\n-----END PRIVATE KEY-----\n"; + Property::set('rebootcontrol-private-key', $existing); + $regen = null; + $key = SSHKey::getPrivateKey($regen); + $this->assertSame($existing, $key); + $this->assertFalse($regen); + } + + public function testGeneratePrivateAndPublicKeyWithOpenSSLIfAvailable(): void + { + if (!function_exists('openssl_pkey_new')) { + $this->markTestSkipped('OpenSSL not available in this environment'); + } + Property::reset(); + $regen = null; + $priv = SSHKey::getPrivateKey($regen); + $this->assertNotNull($priv); + $this->assertTrue($regen); + $this->assertStringContainsString('BEGIN', $priv); + + $pub = SSHKey::getPublicKey(); + $this->assertNotNull($pub); + $this->assertStringStartsWith('ssh-rsa ', $pub); + } +} diff --git a/tests/Modules/RebootControl/SchedulerTest.php b/tests/Modules/RebootControl/SchedulerTest.php new file mode 100644 index 00000000..af9cadf3 --- /dev/null +++ b/tests/Modules/RebootControl/SchedulerTest.php @@ -0,0 +1,71 @@ +<?php + +use PHPUnit\Framework\TestCase; + +/** + * Tests for modules-available/rebootcontrol/inc/scheduler.inc.php + * Uses SQLite-backed DB and real Location + OpeningTimes. + * + */ +class SchedulerTest extends TestCase +{ + protected function setUp(): void + { + Database::resetSchema(); + Render::reset(); + Message::reset(); + User::reset(); + $_GET = $_POST = $_REQUEST = []; + // Use real Location and OpeningTimes + $GLOBALS['__TEST_USE_REAL_CLASSES'] = ['Location']; + require_once __DIR__ . '/../../../modules-available/locations/inc/location.inc.php'; + require_once __DIR__ . '/../../../modules-available/locations/inc/openingtimes.inc.php'; + require_once __DIR__ . '/../../../modules-available/rebootcontrol/inc/scheduler.inc.php'; + // Ensure campus (1) has opening times already from seed; adjust to predictable short window for test stability + $today = date('l'); + $now = time(); + $open = date('H:i', $now + 3600); // opens in 1h + $close = date('H:i', $now + 7200); // closes in 2h + $json = json_encode([[ 'days' => [$today], 'openingtime' => $open, 'closingtime' => $close ]]); + Database::exec('UPDATE location SET openingtime = :ot WHERE locationid = 1', ['ot' => $json]); + } + + public function testSetLocationOptionsCreatesOrDeletesSchedule(): void + { + // Initially, no schedule rows + $row0 = Database::queryFirst('SELECT COUNT(*) AS c FROM reboot_scheduler'); + $this->assertSame(0, (int)$row0['c']); + + // Enable WOL with small offset -> creates a schedule with next execution in future + Scheduler::setLocationOptions(1, ['wol' => true, 'sd' => false, 'wol-offset' => 5, 'sd-offset' => 0, 'ra-mode' => Scheduler::RA_ALWAYS]); + $row = Database::queryFirst('SELECT * FROM reboot_scheduler WHERE locationid = 1'); + $this->assertNotFalse($row); + $this->assertSame('WOL', $row['action']); + $this->assertGreaterThan(time(), (int)$row['nextexecution']); + $opts = json_decode($row['options'], true); + $this->assertIsArray($opts); + $this->assertTrue($opts['wol']); + + // Disable all (and RA_ALWAYS) -> deletes schedule + Scheduler::setLocationOptions(1, ['wol' => false, 'sd' => false, 'wol-offset' => 0, 'sd-offset' => 0, 'ra-mode' => Scheduler::RA_ALWAYS]); + $row2 = Database::queryFirst('SELECT * FROM reboot_scheduler WHERE locationid = 1'); + $this->assertFalse($row2); + } + + public function testUpdateScheduleRecursiveUpdatesChildrenWithoutOwnTimes(): void + { + // Give child (2) no opening times and write options + Database::exec('UPDATE location SET openingtime = NULL WHERE locationid = 2'); + // Assign options to child 2 + Scheduler::setLocationOptions(2, ['wol' => true, 'sd' => false, 'wol-offset' => 0, 'sd-offset' => 0, 'ra-mode' => Scheduler::RA_ALWAYS]); + // Now modify parent (1) opening time again -> should update child's schedule + $today = date('l'); + $json = json_encode([[ 'days' => [$today], 'openingtime' => '23:59', 'closingtime' => '23:59' ]]); + Database::exec('UPDATE location SET openingtime = :ot WHERE locationid = 1', ['ot' => $json]); + // Trigger recalculation by setting location options on parent (even if unchanged) + Scheduler::setLocationOptions(1, ['wol' => true, 'sd' => false, 'wol-offset' => 0, 'sd-offset' => 0, 'ra-mode' => Scheduler::RA_ALWAYS]); + $child = Database::queryFirst('SELECT * FROM reboot_scheduler WHERE locationid = 2'); + $this->assertNotFalse($child); + $this->assertGreaterThanOrEqual(0, (int)$child['nextexecution']); + } +} diff --git a/tests/Modules/SyslogPageTest.php b/tests/Modules/SyslogPageTest.php new file mode 100644 index 00000000..c9b6a3ea --- /dev/null +++ b/tests/Modules/SyslogPageTest.php @@ -0,0 +1,116 @@ +<?php + +use PHPUnit\Framework\TestCase; + +/** + * Syslog page tests adapted to use the SQLite-backed Database backend and real Paginate. + * + */ +class SyslogPageTest extends TestCase +{ + protected function setUp(): void + { + Database::resetSchema(); + + // Reset stubs and superglobals for each test + Render::reset(); + Message::reset(); + Session::reset(); + Property::reset(); + User::reset(); + $_GET = $_POST = $_REQUEST = []; + + // Default: logged in user + User::$loggedIn = true; + User::$id = 1; + } + + private function newSyslogPage(): Page_SysLog + { + // Load the module's page class + require_once __DIR__ . '/../../modules-available/syslog/page.inc.php'; + return new Page_SysLog('syslog'); + } + + public function testRenderDeniesWithoutViewPermission(): void + { + // No 'view' permission + User::$permissions = ['view' => false]; + $mod = $this->newSyslogPage(); + $mod->preprocess(); + $mod->render(); + + $this->assertContains('main.no-permission', Message::$errors, 'Should add no-permission error'); + // Heading template still added, but no 'page-syslog' template + $names = array_column(Render::$templates, 'name'); + $this->assertNotEmpty($names); + $this->assertSame('heading', Render::$templates[0]['name']); + $this->assertNotContains('page-syslog', $names); + } + + public function testRenderWithFiltersAndTypes(): void + { + User::$permissions = ['view' => true]; + // Apply filters via GET (we search for 'login' which exists in seed) + $_GET['filter'] = 'session-open,foo-custom'; // includes one unknown type + $_GET['search'] = 'login'; + $_GET['not'] = '0'; + $_REQUEST = $_GET; + + $mod = $this->newSyslogPage(); + $mod->preprocess(); + $mod->render(); + + // Verify render payload from real Paginate + $names = array_column(Render::$templates, 'name'); + $this->assertContains('page-syslog', $names); + $tpl = null; + foreach (Render::$templates as $t) { + if ($t['name'] === 'page-syslog') { $tpl = $t; break; } + } + $this->assertNotNull($tpl); + $data = $tpl['data']; + $this->assertSame('session-open,foo-custom', $data['filter']); + $this->assertSame('login', $data['search']); + $this->assertSame(false, $data['not']); + $this->assertIsString($data['types']); + $typesArray = json_decode($data['types'], true); + $this->assertIsArray($typesArray); + $ids = array_column($typesArray, 'logtypeid'); + // Should include both DB types and unknown filter value + $this->assertContains('session-open', $ids); + $this->assertContains('partition-temp', $ids); + $this->assertContains('foo-custom', $ids); + + // Each list row enriched with 'date' and 'icon' + $this->assertArrayHasKey('list', $data); + $this->assertNotEmpty($data['list']); + $this->assertArrayHasKey('date', $data['list'][0]); + $this->assertArrayHasKey('icon', $data['list'][0]); + } + + public function testRenderWithAllowedLocationsRestrictionFiltersRows(): void + { + User::$permissions = ['view' => true]; + // Restrict allowed locations (no 0) -> only logs from machines in these locations remain + User::$allowedLocations = [5, 6]; + $_REQUEST = $_GET = []; + + $mod = $this->newSyslogPage(); + $mod->preprocess(); + $mod->render(); + + $tpl = null; + foreach (Render::$templates as $t) { + if ($t['name'] === 'page-syslog') { $tpl = $t; break; } + } + $this->assertNotNull($tpl); + $data = $tpl['data']; + $this->assertArrayHasKey('list', $data); + // With allowedLocations [5,6] and seed machines m1@1, m2@5, only m2 should remain + $this->assertNotEmpty($data['list']); + $uuids = array_column($data['list'], 'machineuuid'); + $this->assertContains('m2', $uuids); + $this->assertNotContains('m1', $uuids); + } +} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..4d819fe7 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,106 @@ +# PHPUnit in this project (no Composer) + +This legacy project does not use Composer. To run tests, use the PHPUnit PHAR. + +Quick start: + +1. Download phpunit.phar (pick a version compatible with your PHP): + + ```bash + curl -L -o phpunit.phar https://phar.phpunit.de/phpunit-9.6.phar + chmod +x phpunit.phar + ``` + + Alternatively, place `phpunit.phar` somewhere in your PATH. + +2. From the project root, run the tests: + + ```bash + php phpunit.phar + # or + php phpunit.phar --configuration phpunit.xml.dist + ``` + +Structure added: +- `phpunit.xml.dist` — PHPUnit configuration using `tests/bootstrap.php`. +- `tests/bootstrap.php` — sets up error reporting and an autoloader matching `./inc/<lowername>.inc.php`. +- `tests/` — test files live here. Initial tests added for `IpUtil` under `tests/Inc/IpUtilTest.php`. + +Notes: +- The bootstrap avoids including `index.php`/`api.php` to prevent side effects; it only loads `config.php` if present and registers an autoloader for `inc/`. +- Tests target self-contained helpers first to build coverage without refactoring entangled code. + +## Testing real classes when stubs exist +Some tests load global stubs (e.g., `Database`, `User`) by default via a stub-first autoloader. To test a real class that also has a stub: + +- Run the test in a separate PHP process so it starts clean and doesn’t inherit classes from prior tests: + - Add these annotations to the test class (or individual test methods): + - `@runTestsInSeparateProcesses` + - `@preserveGlobalState disabled` +- Before the first reference to the class, tell the stub autoloader to allowlist the real class: + ```php + $GLOBALS['__TEST_USE_REAL_CLASSES'] = ['User']; + ``` + Alternatively, set an environment variable when running PHPUnit: + ```bash + TEST_USE_REAL_CLASSES=User php phpunit.phar tests/Inc/UserTest.php + ``` +- Optionally force-load the class immediately afterwards: + ```php + require_once __DIR__ . '/../../inc/user.inc.php'; + ``` + +See `tests/Inc/UserTest.php` for a complete example. + + +## Module testing (modules-available/*/page.inc.php) +This project’s modules define page classes (e.g., `Page_SysLog`) that normally run under `index.php`, which defines a global `Page` class and provides request/rendering infrastructure. For unit tests, a lightweight harness is provided via stubs under `tests/Stubs`: + +- `Page` — minimal base with `preprocess()` and `render()` calling the module’s `doPreprocess()`/`doRender()`. Also provides `Page::getModule()` and `getIdentifier()` as needed by some code. +- `Request` — `any()`, `get()`, `post()` backed by PHP superglobals with simple type casting. +- `Render` — records `addTemplate()` calls for assertions. +- `Permission` — `addGlobalTags()` no-op that fills the provided array with the requested tags. +- `Paginate` — captures the SQL and args, returns queued results in tests, and records the template/data passed to `render()`. +- Existing stubs like `Database`, `User`, `Session`, `Message`, `Dictionary`, and `Property` are reused. + +How to write a module test: +- Reset stubs and superglobals in `setUp()`. +- Make sure your test loads the module file directly: + ```php + require_once __DIR__ . '/../../modules-available/syslog/page.inc.php'; + $page = new Page_SysLog('syslog'); + $page->render(); + ``` +- Drive inputs via `$_GET`, `$_POST`, and the `User`/`Property` stubs. +- Seed DB and pagination data by pushing into stub queues: + ```php + Database::$simpleQueryResponses[] = [ ['logtypeid' => 'x', 'counter' => 1] ]; + Paginate::$queuedResults[] = [ ['dateline' => time(), 'logtypeid' => 'x', ...] ]; + ``` +- Assert on `Paginate::$lastExecArgs`, `Paginate::$lastRender`, and `Render::$templates`. + +See `tests/Modules/SyslogPageTest.php` for a complete example that covers: +- Permission denial path (no `view` permission) +- Filter/search handling and type list augmentation +- Location restriction joining via `getAllowedLocations()` + +## Database-backed tests without MySQL (SQLite test backend) + +Global schema and seed data: +- The SQLite backend now auto-creates a minimal schema on first use and seeds a canonical dataset so tests can share consistent data without duplicating setup. +- Tables created include: location, subnet, machine, user, role, role_x_user, role_x_location, role_x_permission, clientlog, mail_queue, mail_config, audit. +- Seed content highlights: + - Locations: Campus (1) → Building A (2) → Room 101 (4); Building B (3); Offsite (5) + - Subnets: 10.0.0.0/24 @ 1, 10.0.0.128/26 @ 2, 192.168.1.0/24 @ 5 + - Users/roles: alice(1), bob(2); roles Admin(1, builtin), Tech(2) with sample permissions + +Utility helpers (optional): +- `Database::resetSchema()` — drop and recreate the in-memory DB with fresh schema and seed. +- `Database::reseed()` — reinsert the seed data into the existing schema. +- `Database::truncateAll()` — delete rows from all known tables. +- `Database::pdo()` — access the underlying PDO if you need custom SQL. + +Parameter handling: +- Array parameters in `IN (:list)` and bulk inserts like `VALUES :arg` (legacy pattern) are expanded automatically. + +There is a very thin translation layer that tries to convert MySQL-specific syntax into SQLite syntax. It might need improvements and extensions as more tests get added.
\ No newline at end of file diff --git a/tests/Stubs/Crypto.php b/tests/Stubs/Crypto.php new file mode 100644 index 00000000..0e8d13a0 --- /dev/null +++ b/tests/Stubs/Crypto.php @@ -0,0 +1,22 @@ +<?php + +/** + * Minimal Crypto stub for testing code paths that depend on password hashing. + */ +class Crypto +{ + public static function hash6(string $password): string + { + // Deterministic placeholder hash for testing + return 'HASHED-' . $password; + } + + public static function verify(string $password, string $hash): bool + { + // Accept if hash matches a canned value or if it equals our hash6() + if ($hash === 'STORED' && $password === 'ok') { + return true; + } + return $hash === self::hash6($password); + } +} diff --git a/tests/Stubs/Database.php b/tests/Stubs/Database.php new file mode 100644 index 00000000..b0f8f30c --- /dev/null +++ b/tests/Stubs/Database.php @@ -0,0 +1,413 @@ +<?php + +/** + * SQLite-backed Database implementation for tests. + * + * Provides a lightweight, in-memory RDBMS without requiring MySQL, similar in spirit to DbUnit. + * It implements the subset of the legacy Database API used by our tests and by the classes we test. + */ +class Database +{ + private static ?PDO $pdo = null; + private static int $queryCount = 0; + private static float $queryTime = 0.0; + private static bool $initialized = false; + + public static function reset(): void + { + self::$pdo = null; // drop the connection; will be recreated lazily + self::$queryCount = 0; + self::$queryTime = 0.0; + self::$initialized = false; + } + + private static function connect(): PDO + { + if (self::$pdo instanceof PDO) + return self::$pdo; + $pdo = new PDO('sqlite::memory:'); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + // Enable foreign keys if needed + $pdo->exec('PRAGMA foreign_keys = ON'); + // Register lightweight MySQL-compatibility functions used in queries + if (method_exists($pdo, 'sqliteCreateFunction')) { + // UNIX_TIMESTAMP() -> current epoch seconds + $pdo->sqliteCreateFunction('UNIX_TIMESTAMP', function (): int { return time(); }, 0); + } + self::$pdo = $pdo; + self::initializeSchemaAndData(); + return $pdo; + } + + public static function resetSchema(): void + { + // Recreate connection and schema + self::reset(); + self::connect(); + } + + public static function reseed(): void + { + if (!self::$pdo) + self::connect(); + self::seedData(self::$pdo); + } + + public static function truncateAll(): void + { + $pdo = self::connect(); + $tables = ['location', + 'subnet', + 'machine', + 'user', + 'role', + 'role_x_user', + 'role_x_location', + 'role_x_permission', + 'clientlog', + 'mail_queue', + 'mail_config', + 'audit']; + foreach ($tables as $t) { + $pdo->exec("DELETE FROM $t"); + } + } + + private static function initializeSchemaAndData(): void + { + if (self::$initialized) + return; + $pdo = self::$pdo; + // Create tables + $pdo->exec('CREATE TABLE location (locationid INTEGER PRIMARY KEY, parentlocationid INTEGER NOT NULL, locationname TEXT NOT NULL, openingtime TEXT NULL)'); + $pdo->exec('CREATE TABLE subnet (startaddr INTEGER NOT NULL, endaddr INTEGER NOT NULL, locationid INTEGER NOT NULL)'); + $pdo->exec("CREATE TABLE machine (machineuuid TEXT PRIMARY KEY, subnetlocationid INTEGER, locationid INTEGER, fixedlocationid INTEGER, hostname TEXT, clientip TEXT, state TEXT DEFAULT 'OFFLINE')"); + $pdo->exec('CREATE TABLE user (userid INTEGER PRIMARY KEY, login TEXT, passwd TEXT, fullname TEXT, lasteventid INTEGER, permissions INTEGER)'); + $pdo->exec('CREATE TABLE role (roleid INTEGER PRIMARY KEY, rolename TEXT, builtin INTEGER DEFAULT 0, roledescription TEXT)'); + $pdo->exec('CREATE TABLE role_x_user (userid INTEGER, roleid INTEGER)'); + $pdo->exec('CREATE TABLE role_x_location (roleid INTEGER, locationid INTEGER)'); + $pdo->exec('CREATE TABLE role_x_permission (roleid INTEGER, permissionid TEXT)'); + $pdo->exec('CREATE TABLE clientlog (logid INTEGER PRIMARY KEY, dateline INTEGER, logtypeid TEXT, clientip TEXT, machineuuid TEXT, description TEXT, extra TEXT)'); + $pdo->exec('CREATE TABLE mail_queue (mailid INTEGER PRIMARY KEY, configid INTEGER, rcpt TEXT, subject TEXT, body TEXT, dateline INTEGER, nexttry INTEGER DEFAULT 0)'); + $pdo->exec('CREATE TABLE mail_config (configid INTEGER PRIMARY KEY, host TEXT, port INTEGER, ssl TEXT, senderaddress TEXT, replyto TEXT, username TEXT, password TEXT)'); + $pdo->exec('CREATE TABLE audit (id INTEGER PRIMARY KEY, dateline INTEGER, userid INTEGER, ipaddr TEXT, module TEXT, action TEXT, data TEXT, response INTEGER)'); + // Rebootcontrol scheduler table + $pdo->exec('CREATE TABLE reboot_scheduler (locationid INTEGER PRIMARY KEY, action TEXT, nextexecution INTEGER, options TEXT)'); + // Exams module tables + $pdo->exec('CREATE TABLE exams (examid INTEGER PRIMARY KEY, lectureid TEXT NULL, autologin TEXT, starttime INTEGER, endtime INTEGER, description TEXT)'); + $pdo->exec('CREATE TABLE exams_x_location (examid INTEGER, locationid INTEGER NULL)'); + // Simulated sat.* schema as sat_ prefixed tables for SQLite + $pdo->exec('CREATE TABLE sat_user (userid INTEGER PRIMARY KEY, firstname TEXT, lastname TEXT, email TEXT)'); + $pdo->exec('CREATE TABLE sat_lecture (lectureid TEXT PRIMARY KEY, ownerid INTEGER, displayname TEXT, starttime INTEGER, endtime INTEGER, isexam INTEGER, isenabled INTEGER, islocationprivate INTEGER DEFAULT 0)'); + $pdo->exec('CREATE TABLE sat_lecture_x_location (lectureid TEXT, locationid INTEGER)'); + self::seedData($pdo); + self::$initialized = true; + } + + private static function seedData(PDO $pdo): void + { + // Seed a small canonical dataset used by multiple tests + $loc = $pdo->prepare('INSERT INTO location (locationid, parentlocationid, locationname) VALUES (?, ?, ?)'); + $rows = [ + [1, 0, 'Campus'], + [2, 1, 'Building A'], + [3, 1, 'Building B'], + [4, 2, 'Room 101'], + [5, 0, 'Offsite'], + ]; + foreach ($rows as $r) { + $loc->execute($r); + } + // Seed opening times for locations: Campus (1) open all week 00:00-23:59 + $today = date('l'); + $now = time(); + $week = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + $otAllDay = json_encode([ + ['days' => $week, 'openingtime' => '00:00', 'closingtime' => '23:59'] + ]); + $pdo->prepare('UPDATE location SET openingtime = ? WHERE locationid = 1')->execute([$otAllDay]); + // Force a predictable day/time check by crafting a narrow window around now + // XXX: Will fail around midnight, since the shifted opening times will span + // to the next/previous day, but be attached to the current weekday. + // First, move opening time into future (location 5) + $open = date('H:i', $now + 3600); // opens in 1h + $close = date('H:i', $now + 7200); // closes in 2h + $json = json_encode([[ 'days' => [$today], 'openingtime' => $open, 'closingtime' => $close ]]); + Database::exec('UPDATE location SET openingtime = :ot WHERE locationid = :id', ['ot' => $json, 'id' => 5]); + // Now, move opening time into past (location 4) + $open = date('H:i', $now - 7200); // opened 2h ago + $close = date('H:i', $now - 3600); // closed 1h ago + $json = json_encode([[ 'days' => [$today], 'openingtime' => $open, 'closingtime' => $close ]]); + Database::exec('UPDATE location SET openingtime = :ot WHERE locationid = :id', ['ot' => $json, 'id' => 4]); + // Subnets + $sub = $pdo->prepare('INSERT INTO subnet (startaddr, endaddr, locationid) VALUES (?, ?, ?)'); + $sub->execute([sprintf('%u', ip2long('10.0.0.0')), sprintf('%u', ip2long('10.0.0.255')), 1]); + $sub->execute([sprintf('%u', ip2long('10.0.0.128')), sprintf('%u', ip2long('10.0.0.191')), 2]); + $sub->execute([sprintf('%u', ip2long('192.168.1.0')), sprintf('%u', ip2long('192.168.1.255')), 5]); + // Users and roles minimal + $pdo->exec("INSERT INTO user (userid, login, passwd, fullname, permissions) VALUES (1,'alice','STORED','Alice Doe',0), (2,'bob','STORED','Bob',0)"); + $pdo->exec("INSERT INTO role (roleid, rolename, builtin, roledescription) VALUES (1,'Admin',1,''),(2,'Tech',0,'')"); + $pdo->exec("INSERT INTO role_x_user (userid, roleid) VALUES (1,1)"); + $pdo->exec('INSERT INTO role_x_user (userid, roleid) VALUES (1,2)'); + $pdo->exec("INSERT INTO role_x_permission (roleid, permissionid) VALUES (1,'permissionmanager.*'),(2,'permissionmanager.users.view')"); + // Map Admin role (1) globally (NULL -> treated as 0) and Tech role (2) to Building A (2) which has child Room 101 (4) + $pdo->exec('INSERT INTO role_x_location (roleid, locationid) VALUES (1, NULL)'); + $pdo->exec('INSERT INTO role_x_location (roleid, locationid) VALUES (2, 2)'); + // Machine table minimal + $pdo->exec("INSERT INTO machine (machineuuid, subnetlocationid, locationid) VALUES ('uuid-1', NULL, NULL)"); + // Ensure some machines with fixedlocationid/subnetlocationid and details for mismatch checks + $pdo->exec("INSERT INTO machine (machineuuid, subnetlocationid, locationid, fixedlocationid, hostname, clientip) + VALUES ('m1', 1, 1, 5, 'host-m1', '10.0.0.10')"); + $pdo->exec("INSERT INTO machine (machineuuid, subnetlocationid, locationid, fixedlocationid, hostname, clientip) + VALUES ('m2', 5, 5, 5, 'host-m2', '192.168.1.50')"); + // Seed some client log entries within last month for syslog tests (types and listing) + $now = time(); + $ins = $pdo->prepare('INSERT INTO clientlog (dateline, logtypeid, clientip, machineuuid, description, extra) VALUES (?, ?, ?, ?, ?, ?)'); + $ins->execute([$now - 60, 'session-open', '1.2.3.4', 'm1', 'User login', '']); + $ins->execute([$now - 120, 'partition-temp', '2.3.4.5', 'm2', 'Temp high', '']); + $pdo->exec("INSERT INTO machine (machineuuid, subnetlocationid, locationid, fixedlocationid, hostname, clientip) VALUES ('wrong2', 0, NULL, 2, 'host-w2', '10.0.0.77')"); + } + + public static function pdo(): PDO + { + return self::connect(); + } + + private static function startTimer(): float { return microtime(true); } + + private static function stopTimer(float $t0): void + { + self::$queryTime += (microtime(true) - $t0); + self::$queryCount++; + } + + /** + * Expand array parameters and special bulk VALUES :arg syntax used in legacy code + */ + private static function expandParams(string $sql, array $params): array + { + $flat = []; + // Handle bulk insert syntax: VALUES :arg where :arg is array of associative rows + if (preg_match('/VALUES\s*:(\w+)/i', $sql, $m)) { + $key = $m[1]; + $rows = $params[$key] ?? []; + if (!is_array($rows) || empty($rows)) { + // No rows -> create a no-op that inserts zero rows; easiest is to use SELECT with no rows + $sql = preg_replace('/VALUES\s*:\w+/i', "SELECT 1 WHERE 0", $sql); + return [$sql, []]; + } + $cols = array_keys($rows[0]); + $groups = []; + $idx = 0; + foreach ($rows as $r) { + $phs = []; + foreach ($cols as $c) { + $name = ":{$key}_{$c}_{$idx}"; + $flat[$key . '_' . $c . '_' . $idx] = $r[$c]; + $phs[] = $name; + } + $groups[] = '(' . implode(', ', $phs) . ')'; + $idx++; + } + $sql = preg_replace('/VALUES\s*:\w+/i', 'VALUES ' . implode(', ', $groups), $sql); + // Remove the original bulk key so it does not get processed again + unset($params[$key]); + } + // Handle IN (:list) where param value is array + foreach ($params as $name => $value) { + if (is_array($value)) { + $placeholders = []; + foreach (array_values($value) as $i => $val) { + $ph = ":{$name}_{$i}"; + $placeholders[] = $ph; + $flat[$name . '_' . $i] = $val; + } + $sql = preg_replace('/:' . preg_quote($name, '/') . '\b/', implode(', ', $placeholders), $sql); + } else { + $flat[$name] = $value; + } + } + return [$sql, $flat]; + } + + public static function exec(string $sql, array $params = [], ...$rest): int + { + $pdo = self::connect(); + [$sql2, $flat] = self::expandParams($sql, $params); + $sql2 = self::fixupSyntax($sql2); + $t0 = self::startTimer(); + try { + $stmt = $pdo->prepare($sql2); + } catch (PDOException $e) { + error_log($sql2); + } + $stmt->execute($flat); + self::stopTimer($t0); + return $stmt->rowCount(); + } + + /** + * Translates a MySQL-query into an SQLite query. + * Very rudimentary, might need improvements as we go. + */ + private static function fixupSyntax(string $sql): string + { + // MySQL -> SQLite tweaks + $sql = preg_replace('/^\s*INSERT\s+IGNORE/i', 'INSERT OR IGNORE', $sql); + // sat.* schema mapping to sat_* tables for tests + $sql = preg_replace('/\bsat\.lecture_x_location\b/i', 'sat_lecture_x_location', $sql); + $sql = preg_replace('/\bsat\.lecture\b/i', 'sat_lecture', $sql); + $sql = preg_replace('/\bsat\.user\b/i', 'sat_user', $sql); + // GROUP_CONCAT with SEPARATOR -> group_concat(expr, sep) + $sql = preg_replace("/GROUP_CONCAT\s*\(\s*([^\)]*?)\s+SEPARATOR\s+'([^']*)'\s*\)/i", "group_concat($1, '$2')", $sql); + // Generic GROUP_CONCAT -> group_concat + $sql = preg_replace('/GROUP_CONCAT\s*\(/i', 'group_concat(', $sql); + // duplicate key handling + $parts = preg_split('/\bON\s+DUPLICATE\s+KEY\s+UPDATE\b/i', $sql); + if (count($parts) === 2) { + $parts[1] = preg_replace('/\bVALUES\s*\(\s*(\w+)\s*\)/i', 'excluded.$1', $parts[1]); + $sql = implode(' ON CONFLICT DO UPDATE SET ', $parts); + } + return $sql; + } + + public static function lastInsertId(): int + { + return (int)self::connect()->lastInsertId(); + } + + public static function queryFirst(string $sql, array $params = []) + { + $pdo = self::connect(); + [$sql2, $flat] = self::expandParams($sql, $params); + $sql2 = self::fixupSyntax($sql2); + $t0 = self::startTimer(); + $stmt = $pdo->prepare($sql2); + $stmt->execute($flat); + self::stopTimer($t0); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return $row !== false ? $row : false; + } + + public static function queryAll(string $sql, array $params = []) + { + $pdo = self::connect(); + [$sql2, $flat] = self::expandParams($sql, $params); + $sql2 = self::fixupSyntax($sql2); + $t0 = self::startTimer(); + $stmt = $pdo->prepare($sql2); + $stmt->execute($flat); + self::stopTimer($t0); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + public static function simpleQuery(string $sql, array $params = []) + { + $pdo = self::connect(); + [$sql2, $flat] = self::expandParams($sql, $params); + $sql2 = self::fixupSyntax($sql2); + $t0 = self::startTimer(); + $stmt = $pdo->prepare($sql2); + $stmt->execute($flat); + self::stopTimer($t0); + return new SqliteDbResult($stmt); + } + + public static function queryIndexedList(string $sql) + { + $pdo = self::connect(); + $t0 = self::startTimer(); + $sql = self::fixupSyntax($sql); + $stmt = $pdo->query($sql); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + self::stopTimer($t0); + $out = []; + foreach ($rows as $row) { + // Use the first column as key + $key = array_key_first($row); + $out[$row[$key]] = $row; + } + return $out; + } + + public static function queryKeyValueList(string $sql, array $params = []) + { + $pdo = self::connect(); + [$sql2, $flat] = self::expandParams($sql, $params); + $sql2 = self::fixupSyntax($sql2); + $t0 = self::startTimer(); + $stmt = $pdo->prepare($sql2); + $stmt->execute($flat); + $rows = $stmt->fetchAll(PDO::FETCH_NUM); + self::stopTimer($t0); + $out = []; + foreach ($rows as $row) { + if (count($row) >= 2) { + $out[$row[0]] = $row[1]; + } + } + return $out; + } + + /** + * Return rows grouped by a key. If the selected result includes a column named + * 'keyval', it will be used as the grouping key; otherwise, the first column is used. + * Each group contains a single-level numeric array of rows. + */ + public static function queryGroupList(string $sql) + { + $pdo = self::connect(); + $t0 = self::startTimer(); + $sql = self::fixupSyntax($sql); + $stmt = $pdo->query($sql); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + self::stopTimer($t0); + $out = []; + foreach ($rows as $row) { + $key = array_key_exists('keyval', $row) ? $row['keyval'] : $row[array_key_first($row)]; + $out[$key][] = $row; + } + return array_values($out); + } + + public static function getQueryCount(): int { return self::$queryCount; } + + public static function getQueryTime(): float { return self::$queryTime; } +} + +class SqliteDbResult implements IteratorAggregate +{ + private PDOStatement $stmt; + private ?array $cache = null; + + public function __construct(PDOStatement $stmt) + { + $this->stmt = $stmt; + } + + private function ensureCache(): void + { + if ($this->cache === null) { + $this->cache = $this->stmt->fetchAll(PDO::FETCH_ASSOC); + } + } + + public function rowCount(): int + { + $this->ensureCache(); + return count($this->cache); + } + + public function getIterator(): Traversable + { + $this->ensureCache(); + return new ArrayIterator($this->cache); + } + + public function fetchAll(): array + { + $this->ensureCache(); + return $this->cache; + } +} diff --git a/tests/Stubs/Dictionary.php b/tests/Stubs/Dictionary.php new file mode 100644 index 00000000..52e8a894 --- /dev/null +++ b/tests/Stubs/Dictionary.php @@ -0,0 +1,38 @@ +<?php + +/** + * Test stub for the legacy global Dictionary class. + * Provides deterministic number formatting and simple translations. + */ +class Dictionary +{ + public static function number(float $n, int $decimals = 0): string + { + // Deterministic formatting: dot as decimal separator, no thousands separator + return number_format($n, max(0, $decimals), '.', ''); + } + + public static function translate(string $key, $arg = null): string + { + $map = [ + 'lang_today' => 'today', + 'lang_yesterday' => 'yesterday', + 'lang_yes' => 'Yes', + 'lang_no' => 'No', + 'global' => 'Global', + ]; + return $map[$key] ?? $key; + } + + // Additional helpers used by Module + public static function translateFileModule(string $module, string $file, string $key, $default = false) + { + // For tests, pretend no translation exists -> return false so Module falls back to !!name!! or page title fallback + return $default; + } + + public static function getCategoryName(?string $id): string + { + return 'Cat:' . ($id ?? ''); + } +} diff --git a/tests/Stubs/ErrorHandler.php b/tests/Stubs/ErrorHandler.php new file mode 100644 index 00000000..982855f1 --- /dev/null +++ b/tests/Stubs/ErrorHandler.php @@ -0,0 +1,16 @@ +<?php + +class ErrorHandler +{ + public static array $traces = []; + + public static function reset(): void + { + self::$traces = []; + } + + public static function traceError(string $message): void + { + self::$traces[] = $message; + } +} diff --git a/tests/Stubs/EventLog.php b/tests/Stubs/EventLog.php new file mode 100644 index 00000000..6249423c --- /dev/null +++ b/tests/Stubs/EventLog.php @@ -0,0 +1,36 @@ +<?php + +/** + * Test stub for the legacy global EventLog class. + * + * Records info/failure messages so tests can assert on side effects + * without touching the real logging backend. + */ +class EventLog +{ + public static array $info = []; + public static array $failure = []; + public static array $applied = []; + + public static function reset(): void + { + self::$info = []; + self::$failure = []; + self::$applied = []; + } + + public static function info(string $message, string $detail = ''): void + { + self::$info[] = [$message, $detail]; + } + + public static function failure(string $message): void + { + self::$failure[] = $message; + } + + public static function applyFilterRules(string $category, array $data): void + { + self::$applied[] = ['category' => $category, 'data' => $data]; + } +} diff --git a/tests/Stubs/Location.php b/tests/Stubs/Location.php new file mode 100644 index 00000000..6a513f33 --- /dev/null +++ b/tests/Stubs/Location.php @@ -0,0 +1,53 @@ +<?php + +class Location +{ + // Configurable stub state + public static array $rootChains = []; // [locationid => [ancestors...]] + public static array $assoc = []; // [id => ['children' => [ids...]]] + public static array $allIds = []; // cache for getAllLocationIds per id + public static array $tree = []; // for getTree() + public static array $flat = []; // for getLocations() + + public static function reset(): void + { + self::$rootChains = []; + self::$assoc = []; + self::$allIds = []; + self::$tree = []; + self::$flat = []; + } + + public static function getLocationRootChain(int $locationid): array + { + return self::$rootChains[$locationid] ?? [$locationid]; + } + + public static function getLocationsAssoc(): array + { + return self::$assoc; + } + + public static function getAllLocationIds(int $startId, bool $includeZero = false): array + { + if ($startId === 0) { + $ids = array_keys(self::$assoc); + if ($includeZero) array_unshift($ids, 0); + return $ids; + } + if (isset(self::$assoc[$startId])) { + return array_merge([$startId], self::$assoc[$startId]['children'] ?? []); + } + return [$startId]; + } + + public static function getTree(): array + { + return self::$tree; + } + + public static function getLocations(int $start, int $depth, bool $includeSublocations, bool $assocOnly): array + { + return self::$flat; + } +} diff --git a/tests/Stubs/Message.php b/tests/Stubs/Message.php new file mode 100644 index 00000000..bd20d5b1 --- /dev/null +++ b/tests/Stubs/Message.php @@ -0,0 +1,43 @@ +<?php + +/** + * Test stub for the legacy global Message class. + * Records errors and provides minimal API used by Util. + */ +class Message +{ + public static array $errors = []; + public static array $warnings = []; + public static array $infos = []; + + public static function reset(): void + { + self::$errors = []; + self::$warnings = []; + } + + public static function addError(string $key, ...$args): void + { + self::$errors[] = $key; + } + + public static function addWarning(string $key, ...$args): void + { + self::$warnings[] = $key; + } + + public static function hasError(string ...$keys): bool + { + foreach ($keys as $k) { + if (in_array($k, self::$errors, true)) + return true; + } + return false; + } + + public static function toRequest(): string + { + // For our tests we don't need to serialize messages. + return ''; + } +} diff --git a/tests/Stubs/Page.php b/tests/Stubs/Page.php new file mode 100644 index 00000000..68bc98a4 --- /dev/null +++ b/tests/Stubs/Page.php @@ -0,0 +1,44 @@ +<?php + +/** + * Minimal test stub for the global Page base class normally defined in index.php. + * + * Provides a harness to call doPreprocess()/doRender() from tests and exposes + * static helpers used by production code (Page::getModule()). + */ +abstract class Page +{ + private static ?Page $current = null; + + protected string $identifier = ''; + + public function __construct(string $identifier = '') + { + $this->identifier = $identifier; + self::$current = $this; + } + + public static function getModule(): ?Page + { + return self::$current; + } + + public function getIdentifier(): string + { + return $this->identifier ?: static::class; + } + + public function preprocess(): void + { + $this->doPreprocess(); + } + + public function render(): void + { + $this->doRender(); + } + + // Methods the module is expected to implement + abstract protected function doPreprocess(); + abstract protected function doRender(); +} diff --git a/tests/Stubs/Paginate.php b/tests/Stubs/Paginate.php new file mode 100644 index 00000000..5f7c43c8 --- /dev/null +++ b/tests/Stubs/Paginate.php @@ -0,0 +1,2 @@ +<?php +// Intentionally left blank: Load real class instead
\ No newline at end of file diff --git a/tests/Stubs/Permission.php b/tests/Stubs/Permission.php new file mode 100644 index 00000000..b5679548 --- /dev/null +++ b/tests/Stubs/Permission.php @@ -0,0 +1,13 @@ +<?php + +class Permission +{ + public static function addGlobalTags(&$out, $location = null, array $tags = []): void + { + // In tests, just mark provided tags as true + $out = []; + foreach ($tags as $t) { + $out[$t] = true; + } + } +} diff --git a/tests/Stubs/Property.php b/tests/Stubs/Property.php new file mode 100644 index 00000000..2c2a6069 --- /dev/null +++ b/tests/Stubs/Property.php @@ -0,0 +1,51 @@ +<?php + +/** + * Test stub for legacy global Property class. + */ +class Property +{ + public static array $values = []; + + public static function reset(): void + { + self::$values = []; + } + + public static function get(string $key, $default = null) + { + return array_key_exists($key, self::$values) ? self::$values[$key] : $default; + } + + public static function set(string $key, $value): void + { + self::$values[$key] = $value; + } + + // List emulation helpers used by RebootControl + public static function addToList(string $key, string $value, int $max = 50): void + { + $list = self::$values[$key] ?? []; + if (!is_array($list)) $list = []; + $list[] = $value; + // Trim to max items (keep latest entries) + if ($max > 0 && count($list) > $max) { + $list = array_slice($list, -$max); + } + self::$values[$key] = $list; + } + + public static function getList(string $key): array + { + $value = self::$values[$key] ?? []; + return is_array($value) ? $value : []; + } + + public static function removeFromListByKey(string $key, $subkey): void + { + if (!isset(self::$values[$key]) || !is_array(self::$values[$key])) return; + unset(self::$values[$key][$subkey]); + // reindex to keep it simple + self::$values[$key] = array_values(self::$values[$key]); + } +} diff --git a/tests/Stubs/Render.php b/tests/Stubs/Render.php new file mode 100644 index 00000000..774d8306 --- /dev/null +++ b/tests/Stubs/Render.php @@ -0,0 +1,35 @@ +<?php + +class Render +{ + public static array $templates = []; + public static ?string $lastTitle = null; + + public static function reset(): void + { + self::$templates = []; + self::$lastTitle = null; + } + + public static function addTemplate(string $name, array $data = []): void + { + self::$templates[] = ['name' => $name, 'data' => $data]; + } + + public static function setTitle(string $title): void + { + self::$lastTitle = $title; + } + + public static function addInfo(string $key, ...$args): void + { + Message::$infos[] = $key; + } + + // Parser used by permissionmanager role editor helpers and Paginate::render + public static function parse(string $template, array $data = [], ...$rest): string + { + // For testing, just return an identifiable placeholder + return '<parsed:' . $template . '>'; + } +} diff --git a/tests/Stubs/Request.php b/tests/Stubs/Request.php new file mode 100644 index 00000000..5f7c43c8 --- /dev/null +++ b/tests/Stubs/Request.php @@ -0,0 +1,2 @@ +<?php +// Intentionally left blank: Load real class instead
\ No newline at end of file diff --git a/tests/Stubs/Session.php b/tests/Stubs/Session.php new file mode 100644 index 00000000..949a126d --- /dev/null +++ b/tests/Stubs/Session.php @@ -0,0 +1,37 @@ +<?php + +/** + * Test stub for legacy global Session class. + */ +class Session +{ + public static array $store = []; + public static ?array $lastCreate = null; + + public static function reset(): void + { + self::$store = []; + self::$lastCreate = null; + } + + public static function get(string $key) + { + return self::$store[$key] ?? false; + } + + public static function set(string $key, $value, $validMinutes): void + { + self::$store[$key] = $value; + } + + public static function create(string $salt, int $userId, bool $fixedAddress): void + { + self::$lastCreate = ['salt' => $salt, 'userId' => $userId, 'fixed' => $fixedAddress]; + self::$store['uid'] = $userId; + } + + public static function delete(): void + { + self::$store = []; + } +} diff --git a/tests/Stubs/Taskmanager.php b/tests/Stubs/Taskmanager.php new file mode 100644 index 00000000..a2f662a3 --- /dev/null +++ b/tests/Stubs/Taskmanager.php @@ -0,0 +1,113 @@ +<?php + +class Taskmanager +{ + // Mirror production status codes + const NO_SUCH_TASK = 'NO_SUCH_TASK'; + const TASK_FINISHED = 'TASK_FINISHED'; + const TASK_ERROR = 'TASK_ERROR'; + const TASK_WAITING = 'TASK_WAITING'; + const NO_SUCH_INSTANCE = 'NO_SUCH_INSTANCE'; + const TASK_PROCESSING = 'TASK_PROCESSING'; + + public static array $submissions = []; // list of ['id'=>, 'task'=>, 'data'=>] + public static array $statusById = []; // id => ['id'=>, 'statusCode'=>..., 'data'=>[]] + private static int $nextId = 1; + + public static function reset(): void + { + self::$submissions = []; + self::$statusById = []; + self::$nextId = 1; + } + + /** + * Stubbed submit compatible with production signature. + * @return array{id: string, statusCode: string, data: array}|bool + */ + public static function submit(string $task, ?array $data = null, ?bool $async = false) + { + $id = (string)self::$nextId++; + $record = ['id' => $id, 'task' => $task, 'data' => $data ?? []]; + self::$submissions[] = $record; + // Default initial status: waiting + self::$statusById[$id] = ['id' => $id, 'statusCode' => self::TASK_WAITING, 'data' => $record['data']]; + if ($async) return true; + return self::$statusById[$id]; + } + + /** + * @param string|array $task + */ + public static function status($task) + { + if (is_array($task) && isset($task['id'])) { + $task = $task['id']; + } + if (!is_string($task)) return false; + return self::$statusById[$task] ?? ['id' => $task, 'statusCode' => self::NO_SUCH_TASK]; + } + + public static function isFailed($task): bool + { + if (!is_array($task) || !isset($task['statusCode']) || !isset($task['id'])) + return true; + // Consider failed if statusCode is none of waiting/processing/finished + return !in_array($task['statusCode'], [self::TASK_WAITING, self::TASK_PROCESSING, self::TASK_FINISHED], true); + } + + public static function isFinished($task): bool + { + if (!is_array($task) || !isset($task['statusCode']) || !isset($task['id'])) + return false; + return !in_array($task['statusCode'], [self::TASK_WAITING, self::TASK_PROCESSING], true); + } + + public static function isRunning($task): bool + { + if (!is_array($task) || !isset($task['statusCode']) || !isset($task['id'])) + return false; + return in_array($task['statusCode'], [self::TASK_WAITING, self::TASK_PROCESSING], true); + } + + public static function isTask($task): bool + { + if ($task === false) return false; + if (is_string($task)) { + $task = self::status($task); + } + return isset($task['statusCode']) + && $task['statusCode'] !== self::NO_SUCH_INSTANCE + && $task['statusCode'] !== self::NO_SUCH_TASK; + } + + public static function addErrorMessage($task): void + { + if (!class_exists('Message')) return; + if ($task === false) { + Message::addError('main.taskmanager-error'); + return; + } + if (!isset($task['statusCode'])) { + Message::addError('main.taskmanager-format'); + return; + } + if (isset($task['data']['error'])) { + Message::addError('main.task-error', $task['statusCode'] . ' (' . $task['data']['error'] . ')'); + return; + } + Message::addError('main.task-error', $task['statusCode']); + } + + /** + * Release a task: mark as NO_SUCH_TASK from now on + */ + public static function release($task): void + { + if (is_array($task) && isset($task['id'])) { + $task = $task['id']; + } + if (!is_string($task)) return; + self::$statusById[$task] = ['id' => $task, 'statusCode' => self::NO_SUCH_TASK]; + } +} diff --git a/tests/Stubs/TaskmanagerCallback.php b/tests/Stubs/TaskmanagerCallback.php new file mode 100644 index 00000000..3db06df8 --- /dev/null +++ b/tests/Stubs/TaskmanagerCallback.php @@ -0,0 +1,16 @@ +<?php + +class TaskmanagerCallback +{ + public static array $callbacks = []; + + public static function reset(): void + { + self::$callbacks = []; + } + + public static function addCallback(array $task, string $name, $arg): void + { + self::$callbacks[] = ['task' => $task['id'] ?? null, 'name' => $name, 'arg' => $arg]; + } +} diff --git a/tests/Stubs/User.php b/tests/Stubs/User.php new file mode 100644 index 00000000..13c16f3a --- /dev/null +++ b/tests/Stubs/User.php @@ -0,0 +1,50 @@ +<?php + +class User +{ + public static bool $loggedIn = false; + public static int $id = 0; + public static array $permissions = []; + public static array $allowedLocations = [0]; + public static array $allowedLocationsMap = []; + + public static function reset(): void + { + self::$loggedIn = false; + self::$id = 0; + self::$permissions = []; + self::$allowedLocations = [0]; + } + + public static function isLoggedIn(): bool + { + return self::$loggedIn; + } + + public static function getId(): int + { + return self::$id; + } + + public static function hasPermission(string $permission, ?int $locationid = null): bool + { + return self::$permissions[$permission] ?? false; + } + + public static function getAllowedLocations(string $permission): array + { + return self::$allowedLocations; + } + + public static function assertPermission(string $permission, ?int $locationid = null, ?string $redirect = null): void + { + if (!self::hasPermission($permission, $locationid)) { + Message::addError('main.no-permission'); + } + } + + public static function load(): bool + { + return self::$loggedIn; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 00000000..1b2b8688 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,67 @@ +<?php + +// PHPUnit bootstrap for this legacy project without Composer +// - Sets strict error reporting +// - Registers the same autoloader convention used by the app for ./inc + +error_reporting(E_ALL); +ini_set('display_errors', '1'); + +$_SERVER = [ + 'REQUEST_METHOD' => 'GET' +]; + +// Project root detection (this file is tests/bootstrap.php) +$PROJECT_ROOT = dirname(__DIR__); +chdir($PROJECT_ROOT); + +// Register a stub autoloader that prefers test doubles by default, but allows opting into real classes per test +$__stubsDir = $PROJECT_ROOT . '/tests/Stubs'; +if (!isset($GLOBALS['__TEST_USE_REAL_CLASSES']) || !is_array($GLOBALS['__TEST_USE_REAL_CLASSES'])) { + $GLOBALS['__TEST_USE_REAL_CLASSES'] = []; +} +// Hint to AI: Do not mock classes from /inc/ that don't depend on external stuff, for example the Request +// class is unnecessary to mock. +// Allow env var override as well, e.g. TEST_USE_REAL_CLASSES=User,Other +$env = getenv('TEST_USE_REAL_CLASSES'); +if (is_string($env) && $env !== '') { + $parts = array_filter(array_map('trim', explode(',', $env))); + $GLOBALS['__TEST_USE_REAL_CLASSES'] = array_values(array_unique(array_merge($GLOBALS['__TEST_USE_REAL_CLASSES'], $parts))); +} +$__useReal = &$GLOBALS['__TEST_USE_REAL_CLASSES']; // array of class names to force real implementation +spl_autoload_register(function ($class) use ($__stubsDir, &$__useReal) { + // If a test explicitly wants the real class, skip the stub + if (in_array($class, $__useReal, true)) { + return; // let the next autoloader handle it + } + $stubFile = $__stubsDir . '/' . $class . '.php'; + if (is_file($stubFile) && !class_exists($class, false) && !interface_exists($class, false)) { + require_once $stubFile; + } +}, true, true); + +// Optional: load config if present; avoid side effects by not including index.php/api.php +$configFile = $PROJECT_ROOT . '/config.php'; +if (file_exists($configFile)) { + require_once $configFile; +} + +// Define minimal constants if tests or includes expect them +if (!defined('API')) { + define('API', false); +} +if (!defined('AJAX')) { + define('AJAX', false); +} +// Define a fake DSN to satisfy Paginate's MySQL engine check during tests +if (!defined('CONFIG_SQL_DSN')) { + define('CONFIG_SQL_DSN', 'mysql://test'); +} + +// Autoload classes from ./inc which adhere to naming scheme <lowercasename>.inc.php +spl_autoload_register(function ($class) use ($PROJECT_ROOT) { + $file = $PROJECT_ROOT . '/inc/' . preg_replace('/[^a-z0-9]/', '', mb_strtolower($class)) . '.inc.php'; + if (!file_exists($file)) + return; + require_once $file; +}); |
