diff --git a/.github/workflows/phpcsfixer.yml b/.github/workflows/phpcsfixer.yml index 1eb8b18c..5b06c842 100644 --- a/.github/workflows/phpcsfixer.yml +++ b/.github/workflows/phpcsfixer.yml @@ -16,13 +16,13 @@ jobs: fail-fast: false matrix: php_versions: [ - '8.3', + '8.5', ] name: PHP ${{ matrix.php_versions }} runs-on: ubuntu-latest steps: - name: checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: install php ${{ matrix.php_versions }} with extensions uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index b3a2b52a..419f0f1c 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -16,15 +16,15 @@ jobs: fail-fast: false matrix: php_versions: [ - '8.1', - '8.2', '8.3', + '8.4', + '8.5', ] name: PHP ${{ matrix.php_versions }} runs-on: ubuntu-latest steps: - name: checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: install php ${{ matrix.php_versions }} with extensions uses: shivammathur/setup-php@v2 diff --git a/README.md b/README.md index e5888116..e6786c43 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Speed up new development and reduce maintenance costs by using this nifty piece Note that the majority of TS3 PHP Framework development and deployment is done on nginx, so there is more community experience and testing performed on Apache than on other web servers. -You can install the TS3 PHP Framework by [manually downloading](https://github.com/planetteamspeak/ts3phpframework/archive/refs/heads/master.zip) it or using Composer. +Install the TS3 PHP Framework with [Composer](https://getcomposer.org/). Composer generates the PSR-4 autoloader required by the framework. Install the latest available release: @@ -143,10 +143,10 @@ $uri = "serverquery://username:password@[fe80::250:56ff:fe16:1447]:10022/?ssh=1" #### SSL/TLS Connections ([TeaSpeak Server](https://www.teaspeak.de) only) -Secure ServerQuery connections can be established using the optional `tls` parameter: +Secure TeaSpeak ServerQuery connections can be established using the optional `tls` parameter. Certificate verification remains disabled by default for compatibility with self-signed deployments. Enable `tls_verify` only when the server certificate is trusted by PHP and matches the hostname: ```php -$uri = "serverquery://username:password@[fe80::250:56ff:fe16:1447]:10011/?tls=1"; +$uri = "serverquery://username:password@teaspeak.example.com:10011/?tls=1&tls_verify=1"; ``` #### Custom Protocol Welcome Message and/or MOTD ([TeaSpeak Server](https://www.teaspeak.de) only) @@ -187,7 +187,7 @@ Additional: * [RFC 2396 - Section 3](https://tools.ietf.org/html/rfc2396#section-3.4) - Valid URI syntax (specifically, components of) * [PHP.net - rawurlencode](http://us2.php.net/manual/en/function.rawurlencode.php) -#### Usual PHP Code (`require` solution) +#### Usual PHP Code Usual PHP code means a simple created `file.php`, where you start writing your code like this: ```php @@ -200,9 +200,12 @@ When you use this solution, you'll probably start using the TS3 PHP Framework l ```php channelGetByName("I do not exist"); } -catch(TeamSpeak3_Exception $e) +catch(TeamSpeak3Exception $e) { // print the error message returned by the server echo "Error " . $e->getCode() . ": " . $e->getMessage(); @@ -229,7 +232,7 @@ When you use a MVC based software like Symfony, CakePHP, Laravel or something si factory($uri); + $ts3_VirtualServer = TeamSpeak3::factory($uri); // spawn an object for the channel using a specified name $ts3_Channel = $ts3_VirtualServer->channelGetByName("I do not exist"); } - catch(TeamSpeak3_Exception $e) + catch(TeamSpeak3Exception $e) { // print the error message returned by the server return "Error " . $e->getCode() . ": " . $e->getMessage(); diff --git a/composer.json b/composer.json index 941aee0a..cb67fd81 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,7 @@ "description": "Modern use-at-will framework that provides individual components to manage TeamSpeak 3 Server instances", "keywords": ["ts3", "teamspeak","server","query","filetransfer","management","interface","api"], "homepage": "https://www.planetteamspeak.com", - "license": "GPL-3.0", + "license": "MIT", "authors": [ { "name": "Sven 'ScP' Paulsen", @@ -17,24 +17,28 @@ "docs": "https://docs.planetteamspeak.com/ts3/php/framework" }, "config": { - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } }, "require": { - "php": ">=8.1", + "php": ">=8.3", "ext-mbstring": "*", "ext-json": "*", "ext-ctype": "*", - "phpseclib/phpseclib": "^3.0", + "phpseclib/phpseclib": "^4.0", "ext-openssl": "*" }, "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.1", "friendsofphp/php-cs-fixer": "^3.8", "php-coveralls/php-coveralls": "^2.5", "phpcompatibility/php-compatibility": "^9.3", - "phpunit/phpunit": "^9.5", + "phpunit/phpunit": "^12.5", "react/socket": "^1.11", "squizlabs/php_codesniffer": "^3.6", - "symfony/yaml": "^6.0" + "symfony/yaml": "^7.4" }, "autoload": { "psr-4": { @@ -47,7 +51,7 @@ } }, "scripts": { - "compatibility": "\"vendor/bin/phpcs\" -ps --basepath=. --standard=PHPCompatibility --runtime-set testVersion 8.0- src", + "compatibility": "\"vendor/bin/phpcs\" -ps --basepath=. --standard=PHPCompatibility --runtime-set testVersion 8.3- src", "code-style": "\"vendor/bin/php-cs-fixer\" fix --config .php-cs-fixer.php --diff", "tests": [ "\"vendor/bin/phpunit\" --no-coverage" diff --git a/phpunit.xml.dist b/phpunit.xml.dist index be906e13..7a8c0e7f 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,25 +1,14 @@ - - - ./ - - - ./images - ./public - ./tests - ./build - ./vendor - @@ -32,4 +21,16 @@ + + + ./ + + + ./images + ./public + ./tests + ./build + ./vendor + + diff --git a/src/Adapter/FileTransfer.php b/src/Adapter/FileTransfer.php index bad936fe..975ec397 100644 --- a/src/Adapter/FileTransfer.php +++ b/src/Adapter/FileTransfer.php @@ -38,6 +38,8 @@ public function __destruct() if ($this->getTransport() instanceof Transport && $this->getTransport()->isConnected()) { $this->getTransport()->disconnect(); } + + Profiler::remove(spl_object_hash($this)); } /** @@ -105,54 +107,55 @@ public function upload(string $ftkey, int $seek, string $data): void */ public function download(string $ftkey, int $size, bool $passthru = false) { - $this->init($ftkey); - if ($passthru) { - $this->passthru($size); + $this->downloadTo($ftkey, $size, static function (StringHelper $data): void { + echo $data; + }); return; } $buff = new StringHelper(""); - $pack = 4096; - - Signal::getInstance()->emit("filetransferDownloadStarted", $ftkey, count($buff), $size); - - for ($seek = 0; $seek < $size;) { - $rest = $size - $seek; - $pack = min($rest, $pack); - $data = $this->getTransport()->read(min($rest, $pack)); - $seek = $seek + $pack; - + $this->downloadTo($ftkey, $size, static function (StringHelper $data) use ($buff): void { $buff->append($data); - - Signal::getInstance()->emit("filetransferDownloadProgress", $ftkey, count($buff), $size); - } - - $this->getProfiler()->stop(); - - Signal::getInstance()->emit("filetransferDownloadFinished", $ftkey, count($buff), $size); - - if (strlen($buff) != $size) { - throw new FileTransferException("incomplete file download (" . count($buff) . " of " . $size . " bytes)"); - } + }); return $buff; } /** - * Outputs all remaining data on a TeamSpeak 3 file transfer stream using PHP's fpassthru() - * function. + * Downloads a file and passes each received chunk to the given consumer. * + * @param string $ftkey * @param integer $size + * @param callable $consumer * @return void * @throws FileTransferException */ - protected function passthru(int $size): void + public function downloadTo(string $ftkey, int $size, callable $consumer): void { - $buff_size = fpassthru($this->getTransport()->getStream()); + $this->init($ftkey); + $pack = 4096; + $seek = 0; + + Signal::getInstance()->emit("filetransferDownloadStarted", $ftkey, $seek, $size); + + try { + while ($seek < $size) { + $data = $this->getTransport()->read(min($size - $seek, $pack)); - if ($buff_size != $size) { - throw new FileTransferException("incomplete file download (" . $buff_size . " of " . $size . " bytes)"); + if (count($data) === 0) { + throw new FileTransferException("incomplete file download (" . $seek . " of " . $size . " bytes)"); + } + + $consumer($data); + $seek += count($data); + + Signal::getInstance()->emit("filetransferDownloadProgress", $ftkey, $seek, $size); + } + } finally { + $this->getProfiler()->stop(); } + + Signal::getInstance()->emit("filetransferDownloadFinished", $ftkey, $seek, $size); } } diff --git a/src/Adapter/MockServerQuery.php b/src/Adapter/MockServerQuery.php index 3a4e71dc..e051cd96 100644 --- a/src/Adapter/MockServerQuery.php +++ b/src/Adapter/MockServerQuery.php @@ -26,9 +26,7 @@ protected function syn(): void $rdy = $this->getTransport()->readLine(); - if (!$rdy->startsWith(TeamSpeak3::TS3_PROTO_IDENT) && !$rdy->startsWith(TeamSpeak3::TEA_PROTO_IDENT) && !(defined("CUSTOM_PROTO_IDENT") && $rdy->startsWith(CUSTOM_PROTO_IDENT))) { - throw new AdapterException("invalid reply from the server (" . $rdy . ")"); - } + $this->validateProtocolGreeting($rdy); Signal::getInstance()->emit("serverqueryConnected", $this); } diff --git a/src/Adapter/ServerQuery.php b/src/Adapter/ServerQuery.php index e0ecde2c..3ae2aeca 100644 --- a/src/Adapter/ServerQuery.php +++ b/src/Adapter/ServerQuery.php @@ -64,13 +64,25 @@ protected function syn(): void $rdy = $this->getTransport()->readLine(); - if (!$rdy->startsWith(TeamSpeak3::TS3_PROTO_IDENT) && !$rdy->startsWith(TeamSpeak3::TEA_PROTO_IDENT) && !(defined("CUSTOM_PROTO_IDENT") && $rdy->startsWith(CUSTOM_PROTO_IDENT))) { - throw new AdapterException("invalid reply from the server (" . $rdy . ")"); - } + $this->validateProtocolGreeting($rdy); Signal::getInstance()->emit("serverqueryConnected", $this); } + /** + * Validates a ServerQuery protocol greeting. + * + * @param StringHelper $greeting + * @return void + * @throws AdapterException + */ + protected function validateProtocolGreeting(StringHelper $greeting): void + { + if (!$greeting->startsWith(TeamSpeak3::TS3_PROTO_IDENT) && !$greeting->startsWith(TeamSpeak3::TEA_PROTO_IDENT) && !$greeting->startsWith(TeamSpeak3::GREENTEA_PROTO_IDENT) && !(defined("CUSTOM_PROTO_IDENT") && $greeting->startsWith(CUSTOM_PROTO_IDENT))) { + throw new AdapterException("invalid reply from the server (" . $greeting . ")"); + } + } + /** * The ServerQuery destructor. * @@ -80,6 +92,7 @@ public function __destruct() { // do not disconnect, when acting as bot in non-blocking mode if (! $this->getTransport()->getConfig("blocking")) { + Profiler::remove(spl_object_hash($this)); return; } @@ -87,9 +100,11 @@ public function __destruct() try { $this->request("quit"); } catch (AdapterException) { - return; + // The transport destructor will close an unavailable connection. } } + + Profiler::remove(spl_object_hash($this)); } /** diff --git a/src/Adapter/ServerQuery/Event.php b/src/Adapter/ServerQuery/Event.php index e6c49e85..d9bb1a45 100644 --- a/src/Adapter/ServerQuery/Event.php +++ b/src/Adapter/ServerQuery/Event.php @@ -54,7 +54,9 @@ public function __construct(StringHelper $evt, Host $con = null) throw new AdapterException("invalid notification event format"); } - list($type, $data) = $evt->split(TeamSpeak3::SEPARATOR_CELL, 2); + $parts = $evt->split(TeamSpeak3::SEPARATOR_CELL, 2); + $type = $parts[0]; + $data = $parts[1] ?? null; if (empty($data)) { throw new AdapterException("invalid notification event data"); diff --git a/src/Adapter/ServerQuery/Reply.php b/src/Adapter/ServerQuery/Reply.php index 68872f80..80ea6e30 100644 --- a/src/Adapter/ServerQuery/Reply.php +++ b/src/Adapter/ServerQuery/Reply.php @@ -86,7 +86,7 @@ public function __construct(array $rpl, string $cmd = "", Host $con = null, bool */ public function toString(): ?StringHelper { - return (!func_num_args()) ? $this->rpl->unescape() : $this->rpl; + return (!func_num_args()) ? (new StringHelper($this->rpl->toString()))->unescape() : $this->rpl; } /** @@ -300,7 +300,7 @@ protected function fetchError(StringHelper $err): void protected function fetchReply(array $rpl): void { foreach ($rpl as $key => $val) { - if ($val->startsWith(TeamSpeak3::TS3_MOTD_PREFIX) || $val->startsWith(TeamSpeak3::TEA_MOTD_PREFIX) || (defined("CUSTOM_MOTD_PREFIX") && $val->startsWith(CUSTOM_MOTD_PREFIX))) { + if ($val->startsWith(TeamSpeak3::TS3_MOTD_PREFIX) || $val->startsWith(TeamSpeak3::TEA_MOTD_PREFIX) || $val->startsWith(TeamSpeak3::GREENTEA_MOTD_PREFIX) || (defined("CUSTOM_MOTD_PREFIX") && $val->startsWith(CUSTOM_MOTD_PREFIX))) { unset($rpl[$key]); } elseif ($val->startsWith(TeamSpeak3::EVENT)) { $this->evt[] = new Event($val, $this->con); diff --git a/src/Helper/Char.php b/src/Helper/Char.php index 42ed5ab1..6bc7878d 100644 --- a/src/Helper/Char.php +++ b/src/Helper/Char.php @@ -199,11 +199,11 @@ public function toHex(): string */ public static function fromHex(string $hex): Char { - if (strlen($hex) != 2) { + if (strlen($hex) != 2 || !ctype_xdigit($hex)) { throw new HelperException("given parameter '" . $hex . "' is not a valid hexadecimal number"); } - return new self(chr(hexdec($hex))); + return new self(hex2bin($hex)); } /** diff --git a/src/Helper/Convert.php b/src/Helper/Convert.php index 9a259f19..eba781be 100644 --- a/src/Helper/Convert.php +++ b/src/Helper/Convert.php @@ -57,17 +57,19 @@ public static function bytes(int $bytes, int $precision = 10): string * Converts seconds/milliseconds to a human-readable value. * * Note: Assumes non-negative integer, but no validation - * @param integer $seconds + * @param float $seconds * @param boolean $is_ms * @param string $format * @return string * @todo: Handle negative integer $seconds, or invalidate * */ - public static function seconds(int $seconds, bool $is_ms = false, string $format = "%aD %H:%I:%S"): string + public static function seconds(float $seconds, bool $is_ms = false, string $format = "%r%aD %H:%I:%S"): string { if ($is_ms) { - $seconds = $seconds / 1000; + $seconds = intval($seconds) / 1000; + } else { + $seconds = intval($seconds); } $current_datetime = new DateTime("@0"); diff --git a/src/Helper/Profiler.php b/src/Helper/Profiler.php index ec8afda0..6e633598 100644 --- a/src/Helper/Profiler.php +++ b/src/Helper/Profiler.php @@ -30,6 +30,17 @@ public static function init(string $name = "default"): void self::$timers[$name] = new Timer($name); } + /** + * Removes a timer which is no longer associated with a live adapter. + * + * @param string $name + * @return void + */ + public static function remove(string $name): void + { + unset(self::$timers[$name]); + } + /** * Starts a timer. * diff --git a/src/Helper/Signal.php b/src/Helper/Signal.php index afc7d863..b9572601 100644 --- a/src/Helper/Signal.php +++ b/src/Helper/Signal.php @@ -40,13 +40,14 @@ class Signal */ public function emit(string $signal, mixed $params = null): mixed { + $args = func_get_args(); + if (!$this->hasHandlers($signal)) { return null; } if (!is_array($params)) { - $params = func_get_args(); - $params = array_slice($params, 1); + $params = array_slice($args, 1); } foreach ($this->sigslots[$signal] as $slot) { @@ -171,6 +172,16 @@ public function clearHandlers(string $signal): void } } + /** + * Clears all registered signal handlers. + * + * @return void + */ + public function clearAllHandlers(): void + { + $this->sigslots = []; + } + /** * Returns a singleton instance of PlanetTeamSpeak\TeamSpeak3Framework\Helper\Signal. * diff --git a/src/Helper/StringHelper.php b/src/Helper/StringHelper.php index c2bb19f3..d90bdd9a 100644 --- a/src/Helper/StringHelper.php +++ b/src/Helper/StringHelper.php @@ -381,8 +381,13 @@ public function isInt(): bool */ public function toInt(): int { - if ($this->string == pow(2, 63) || $this->string == pow(2, 64) || $this->string > pow(2, 31)) { - return -1; + if (ctype_digit($this->string)) { + $value = ltrim($this->string, "0") ?: "0"; + $max = (string)PHP_INT_MAX; + + if (strlen($value) > strlen($max) || (strlen($value) === strlen($max) && strcmp($value, $max) > 0)) { + return -1; + } } return intval($this->string); @@ -426,17 +431,7 @@ public function toSha1(): string */ public function isUtf8(): bool { - $pattern = []; - - $pattern[] = "[\xC2-\xDF][\x80-\xBF]"; // non-overlong 2-byte - $pattern[] = "\xE0[\xA0-\xBF][\x80-\xBF]"; // excluding overlongs - $pattern[] = "[\xE1-\xEC\xEE\xEF][\x80-\xBF]{2}"; // straight 3-byte - $pattern[] = "\xED[\x80-\x9F][\x80-\xBF]"; // excluding surrogates - $pattern[] = "\xF0[\x90-\xBF][\x80-\xBF]{2}"; // planes 1-3 - $pattern[] = "[\xF1-\xF3][\x80-\xBF]{3}"; // planes 4-15 - $pattern[] = "\xF4[\x80-\x8F][\x80-\xBF]{2}"; // plane 16 - - return (bool)preg_match("%(?:" . implode("|", $pattern) . ")+%xs", $this->string); + return mb_check_encoding($this->string, 'UTF-8'); } /** @@ -468,10 +463,17 @@ public function toBase64(): string * * @param string $base64 * @return self + * @throws HelperException */ public static function fromBase64(string $base64): StringHelper { - return new self(base64_decode($base64)); + $string = base64_decode($base64, true); + + if ($string === false) { + throw new HelperException("given parameter is not valid base64 data"); + } + + return new self($string); } /** @@ -481,13 +483,7 @@ public static function fromBase64(string $base64): StringHelper */ public function toHex(): string { - $hex = ""; - - foreach ($this as $char) { - $hex .= $char->toHex(); - } - - return $hex; + return strtoupper(bin2hex($this->string)); } /** @@ -499,17 +495,11 @@ public function toHex(): string */ public static function fromHex(string $hex): StringHelper { - $string = ""; - - if (strlen($hex) % 2 == 1) { + if (strlen($hex) % 2 == 1 || ($hex !== "" && !ctype_xdigit($hex))) { throw new HelperException("given parameter '" . $hex . "' is not a valid hexadecimal number"); } - foreach (str_split($hex, 2) as $chunk) { - $string .= chr(hexdec($chunk)); - } - - return new self($string); + return new self(hex2bin($hex)); } /** diff --git a/src/Helper/Uri.php b/src/Helper/Uri.php index 7163fde4..e910f7fe 100644 --- a/src/Helper/Uri.php +++ b/src/Helper/Uri.php @@ -340,10 +340,10 @@ public function getHost(mixed $default = null): ?StringHelper /** * Returns TRUE if the port is valid. * - * @param integer|null $port + * @param mixed|null $port * @return boolean */ - public function checkPort(int $port = null): bool + public function checkPort(mixed $port = null): bool { if ($port === null) { if ($this->port instanceof StringHelper) { @@ -353,15 +353,9 @@ public function checkPort(int $port = null): bool } } - switch ($port) { - case str_starts_with($port, '-'): - case $port < 0: - case !is_int($port): - case !filter_var($port, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 65535]]): - return false; - } - - return true; + return is_int($port) + && $port > 0 + && filter_var($port, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 65535]]) !== false; } /** @@ -517,7 +511,7 @@ public function getQueryVar(string $key, mixed $default = null): mixed return $default; } - parse_str(rawurldecode($this->query), $queryArray); + parse_str($this->query, $queryArray); if (array_key_exists($key, $queryArray)) { $val = $queryArray[$key]; @@ -591,7 +585,7 @@ public function getFragment(mixed $default = null): ?StringHelper */ public static function getUserParam(string $key, mixed $default = null): mixed { - return (array_key_exists($key, $_REQUEST) && !empty($_REQUEST[$key])) ? self::stripslashesRecursive($_REQUEST[$key]) : $default; + return array_key_exists($key, $_REQUEST) ? self::stripslashesRecursive($_REQUEST[$key]) : $default; } /** @@ -603,7 +597,7 @@ public static function getUserParam(string $key, mixed $default = null): mixed */ public static function getHostParam(string $key, mixed $default = null): mixed { - return (array_key_exists($key, $_SERVER) && !empty($_SERVER[$key])) ? $_SERVER[$key] : $default; + return array_key_exists($key, $_SERVER) ? $_SERVER[$key] : $default; } /** @@ -615,7 +609,7 @@ public static function getHostParam(string $key, mixed $default = null): mixed */ public static function getSessParam(string $key, mixed $default = null): mixed { - return (array_key_exists($key, $_SESSION) && !empty($_SESSION[$key])) ? $_SESSION[$key] : $default; + return array_key_exists($key, $_SESSION) ? $_SESSION[$key] : $default; } /** diff --git a/src/Node/Channel.php b/src/Node/Channel.php index 734a867f..6a8ccbd4 100644 --- a/src/Node/Channel.php +++ b/src/Node/Channel.php @@ -71,7 +71,7 @@ public function subChannelGetById(int $cid): Channel throw new ServerQueryException("invalid channelID", 0x300); } - return $this->channelList[$cid]; + return $this->subChannelList()[$cid]; } /** @@ -81,7 +81,7 @@ public function subChannelGetById(int $cid): Channel * @return Channel * @throws ServerQueryException */ - public function subChannelGetByName(int $name): Channel + public function subChannelGetByName(string $name): Channel { foreach ($this->subChannelList() as $channel) { if ($channel["channel_name"] == $name) { @@ -124,7 +124,7 @@ public function clientGetById(int $clid): Client throw new ServerQueryException("invalid clientID", 0x200); } - return $this->clientList[$clid]; + return $this->clientList()[$clid]; } /** @@ -134,7 +134,7 @@ public function clientGetById(int $clid): Client * @return Client * @throws ServerQueryException */ - public function clientGetByName(int $name): Client + public function clientGetByName(string $name): Client { foreach ($this->clientList() as $client) { if ($client["client_nickname"] == $name) { @@ -392,7 +392,7 @@ public function iconDownload() return; } - $download = $this->getParent()->transferInitDownload(rand(0x0000, 0xFFFF), 0, $this->iconGetName("channel_icon_id")); + $download = $this->getParent()->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $this->iconGetName("channel_icon_id")); $transfer = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"]); return $transfer->download($download["ftkey"], $download["size"]); diff --git a/src/Node/Client.php b/src/Node/Client.php index 90397bc5..6c4d8e24 100644 --- a/src/Node/Client.php +++ b/src/Node/Client.php @@ -304,7 +304,7 @@ public function avatarDownload() return; } - $download = $this->getParent()->transferInitDownload(rand(0x0000, 0xFFFF), 0, $this->avatarGetName()); + $download = $this->getParent()->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $this->avatarGetName()); $transfer = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"]); return $transfer->download($download["ftkey"], $download["size"]); @@ -398,7 +398,7 @@ public function iconDownload() return; } - $download = $this->getParent()->transferInitDownload(rand(0x0000, 0xFFFF), 0, $this->iconGetName("client_icon_id")); + $download = $this->getParent()->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $this->iconGetName("client_icon_id")); $transfer = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"]); return $transfer->download($download["ftkey"], $download["size"]); diff --git a/src/Node/Group.php b/src/Node/Group.php index e09d3a89..f5c3fe72 100644 --- a/src/Node/Group.php +++ b/src/Node/Group.php @@ -51,7 +51,7 @@ public function iconDownload() return; } - $download = $this->getParent()->transferInitDownload(rand(0x0000, 0xFFFF), 0, $this->iconGetName("iconid")); + $download = $this->getParent()->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $this->iconGetName("iconid")); $transfer = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"]); return $transfer->download($download["ftkey"], $download["size"]); diff --git a/src/Node/Host.php b/src/Node/Host.php index 2d31fba0..e879b292 100644 --- a/src/Node/Host.php +++ b/src/Node/Host.php @@ -134,12 +134,13 @@ public function version(string $ident = null): mixed */ public function serverSelect(int $sid, bool $virtual = null): void { + $getargs = func_get_args(); + if ($this->whoami !== null && $this->serverSelectedId() == $sid) { return; } $virtual = ($virtual !== null) ? $virtual : $this->start_offline_virtual; - $getargs = func_get_args(); if ($sid != 0 && $this->predefined_query_name !== null) { $this->execute("use", ["sid" => $sid, "client_nickname" => (string)$this->predefined_query_name, $virtual ? "-virtual" : null]); @@ -184,12 +185,13 @@ public function serverSelectById(int $sid, bool $virtual = null): void */ public function serverSelectByPort(int $port, bool $virtual = null): void { + $getargs = func_get_args(); + if ($this->whoami !== null && $this->serverSelectedPort() == $port) { return; } $virtual = ($virtual !== null) ? $virtual : $this->start_offline_virtual; - $getargs = func_get_args(); if ($port != 0 && $this->predefined_query_name !== null) { $this->execute("use", ["port" => $port, "client_nickname" => (string)$this->predefined_query_name, $virtual ? "-virtual" : null]); diff --git a/src/Node/Node.php b/src/Node/Node.php index d8f739aa..a367f474 100644 --- a/src/Node/Node.php +++ b/src/Node/Node.php @@ -152,7 +152,7 @@ public function iconGetName(string $key): StringHelper * @param string $prefix * @return string */ - public function getClass(string $prefix = "ts3_"): string + public function getClass(?string $prefix = "ts3_"): string { if ($this instanceof Channel && $this->isSpacer()) { return $prefix . "spacer"; @@ -160,7 +160,9 @@ public function getClass(string $prefix = "ts3_"): string return $prefix . "query"; } - return $prefix . StringHelper::factory(get_class($this))->section("_", 2)->toLower(); + $class = StringHelper::factory(str_replace("\\", "/", get_class($this)))->split("/"); + + return $prefix . array_pop($class)->toLower(); } /** @@ -225,23 +227,28 @@ public function getViewer(ViewerInterface $viewer): string protected function filterList(array $nodes = [], array $rules = []): array { if (!empty($rules)) { - foreach ($nodes as $node) { + foreach ($nodes as $nodeKey => $node) { if (!$node instanceof Node) { continue; } $props = $node->getInfo(false); - $props = array_intersect_key($props, $rules); - foreach ($props as $key => $val) { + foreach ($rules as $key => $rule) { + if (!array_key_exists($key, $props)) { + unset($nodes[$nodeKey]); + break; + } + + $val = $props[$key]; if ($val instanceof StringHelper) { - $match = $val->contains($rules[$key], true); + $match = $val->contains($rule, true); } else { - $match = $val == $rules[$key]; + $match = $val == $rule; } if ($match === false) { - unset($nodes[$node->getId()]); + unset($nodes[$nodeKey]); } } } diff --git a/src/Node/Server.php b/src/Node/Server.php index 8545e0f9..30f30933 100644 --- a/src/Node/Server.php +++ b/src/Node/Server.php @@ -1828,7 +1828,7 @@ public function iconDownload(string $iconname = null) $name = $this->iconGetName("virtualserver_icon_id"); } - $download = $this->transferInitDownload(rand(0x0000, 0xFFFF), 0, $name); + $download = $this->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $name); $transfer = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"]); return $transfer->download($download["ftkey"], $download["size"]); @@ -1848,7 +1848,7 @@ public function iconUpload(string $data): int $crc = crc32($data); $size = strlen($data); - $upload = $this->transferInitUpload(rand(0x0000, 0xFFFF), 0, "/icon_" . $crc, $size); + $upload = $this->transferInitUpload(TeamSpeak3::generateTransferClientId(), 0, "/icon_" . $crc, $size); $transfer = TeamSpeak3::factory("filetransfer://" . (str_contains($upload["host"], ":") ? "[" . $upload["host"] . "]" : $upload["host"]) . ":" . $upload["port"]); $transfer->upload($upload["ftkey"], $upload["seekpos"], $data); @@ -2565,7 +2565,14 @@ protected function fetchNodeList() foreach ($this->channelList() as $channel) { if ($channel["pid"] == 0) { - $this->nodeList[] = $channel; + try { + $channel->count(); + $this->nodeList[] = $channel; + } catch (ServerQueryException $e) { + if ($e->getCode() != 0xA08) { + throw $e; + } + } } } } @@ -2621,8 +2628,19 @@ protected static function sortGroupList(Node $a, Node $b): int return 0; } - if ($a->getProperty("sortid", 0) != $b->getProperty("sortid", 0) && $a->getProperty("sortid", 0) != 0 && $b->getProperty("sortid", 0) != 0) { - return ($a->getProperty("sortid", 0) < $b->getProperty("sortid", 0)) ? -1 : 1; + $aSortId = $a->getProperty("sortid", 0); + $bSortId = $b->getProperty("sortid", 0); + + if ($aSortId instanceof StringHelper) { + $aSortId = $aSortId->toInt(); + } + + if ($bSortId instanceof StringHelper) { + $bSortId = $bSortId->toInt(); + } + + if ($aSortId != $bSortId && $aSortId != 0 && $bSortId != 0) { + return ($aSortId < $bSortId) ? -1 : 1; } return ($a->getId() < $b->getId()) ? -1 : 1; diff --git a/src/TeamSpeak3.php b/src/TeamSpeak3.php index d54a49c2..0a6b1150 100644 --- a/src/TeamSpeak3.php +++ b/src/TeamSpeak3.php @@ -49,6 +49,17 @@ */ class TeamSpeak3 { + /** + * Generates a client-side identifier for a file transfer. + * + * @return int + * @throws \Random\RandomException + */ + public static function generateTransferClientId(): int + { + return random_int(0x0000, 0xFFFF); + } + /** * TeamSpeak 3 protocol welcome message. */ @@ -69,6 +80,16 @@ class TeamSpeak3 */ public const TEA_MOTD_PREFIX = "Welcome"; + /** + * GreenTeaSpeak protocol welcome message. + */ + public const GREENTEA_PROTO_IDENT = "GreenTeaSpeak"; + + /** + * GreenTeaSpeak protocol greeting message prefix. + */ + public const GREENTEA_MOTD_PREFIX = "Welcome"; + /** * TeamSpeak 3 protocol error message prefix. */ @@ -316,6 +337,7 @@ class TeamSpeak3 * - timeout * - blocking * - tls (TeaSpeak only) + * - tls_verify * - ssh (TeamSpeak only) * - nickname * - no_query_clients @@ -333,7 +355,7 @@ class TeamSpeak3 * === URI Examples === * - serverquery://127.0.0.1:10011/ * - serverquery://127.0.0.1:10022/?ssh=1 (TeamSpeak ONLY) - * - serverquery://127.0.0.1:10011/?tls=1 (TeaSpeak ONLY) + * - serverquery://teaspeak.example.com:10011/?tls=1&tls_verify=1 (TeaSpeak ONLY) * - serverquery://127.0.0.1:10022/?ssh=1&server_port=9987 * - serverquery://127.0.0.1:10011/?server_port=9987&channel_id=1 * - serverquery://127.0.0.1:10011/?server_port=9987&channel_id=1#no_query_clients @@ -354,7 +376,7 @@ public static function factory(string $uri): Host|Server|ServerQuery|MockServerQ $uri = new Uri($uri); $adapter = self::getAdapterName($uri->getScheme()); - $options = ["host" => $uri->getHost(), "port" => $uri->getPort(), "timeout" => (int)$uri->getQueryVar("timeout", 10), "blocking" => (int)$uri->getQueryVar("blocking", 1), "tls" => (int)$uri->getQueryVar("tls", 0), "ssh" => (int)$uri->getQueryVar("ssh", 0)]; + $options = ["host" => $uri->getHost(), "port" => $uri->getPort(), "timeout" => (int)$uri->getQueryVar("timeout", 10), "blocking" => (int)$uri->getQueryVar("blocking", 1), "tls" => (int)$uri->getQueryVar("tls", 0), "tls_verify" => (int)$uri->getQueryVar("tls_verify", 0), "ssh" => (int)$uri->getQueryVar("ssh", 0)]; self::loadClass($adapter); diff --git a/src/Transport/MockTCP.php b/src/Transport/MockTCP.php index 2d7f06c5..5bd38ddc 100644 --- a/src/Transport/MockTCP.php +++ b/src/Transport/MockTCP.php @@ -25,7 +25,7 @@ public function connect(): void return; } - $this->reply = sprintf("%s\n%s\n", self::S_WELCOME_L0, self::S_WELCOME_L1); + $this->reply = sprintf("%s\n%s\n", $this->config['welcome'] ?? self::S_WELCOME_L0, self::S_WELCOME_L1); $this->stream = true; } diff --git a/src/Transport/TCP.php b/src/Transport/TCP.php index a1f2b39b..a70d3cd5 100644 --- a/src/Transport/TCP.php +++ b/src/Transport/TCP.php @@ -35,16 +35,23 @@ public function connect(): void if (empty($this->config["ssh"])) { $address = "tcp://" . (str_contains($host, ":") ? "[" . $host . "]" : $host) . ":" . $port; - $options = empty($this->config["tls"]) ? [] : ["ssl" => ["allow_self_signed" => true, "verify_peer" => false, "verify_peer_name" => false]]; + $verify = !empty($this->config["tls_verify"]); + $options = empty($this->config["tls"]) ? [] : ["ssl" => ["allow_self_signed" => !$verify, "verify_peer" => $verify, "verify_peer_name" => $verify]]; + $errno = 0; + $errstr = ''; - $this->stream = @stream_socket_client($address, $errno, $errstr, $this->config["timeout"], STREAM_CLIENT_CONNECT, stream_context_create($options)); + $this->stream = $this->openSocket($address, $errno, $errstr, $this->config["timeout"], $options); if ($this->stream === false) { - throw new TransportException(StringHelper::factory($errstr)->toUtf8()->toString(), $errno); + $message = $errstr ?: "failed to connect to server '$host:$port'"; + throw new TransportException(StringHelper::factory($message)->toUtf8()->toString(), $errno); } if (!empty($this->config["tls"])) { - stream_socket_enable_crypto($this->stream, true, STREAM_CRYPTO_METHOD_SSLv23_CLIENT); + if (!$this->enableCrypto()) { + $this->disconnect(); + throw new TransportException("failed to enable TLS for server '$host:$port'"); + } } } else { $this->session = @ssh2_connect($host, $port); @@ -68,6 +75,26 @@ public function connect(): void @stream_set_blocking($this->stream, $blocking ? 1 : 0); } + /** + * Opens the TCP socket. + * + * @return mixed + */ + protected function openSocket(string $address, int &$errno, string &$errstr, int $timeout, array $options): mixed + { + return @stream_socket_client($address, $errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, stream_context_create($options)); + } + + /** + * Enables TLS encryption on the connected stream. + * + * @return bool + */ + protected function enableCrypto(): bool + { + return stream_socket_enable_crypto($this->stream, true, STREAM_CRYPTO_METHOD_SSLv23_CLIENT); + } + /** * Disconnects from a remote server. * @@ -79,12 +106,18 @@ public function disconnect(): void return; } + if (is_resource($this->stream)) { + @fclose($this->stream); + } + $this->stream = null; if (is_resource($this->session)) { @ssh2_disconnect($this->session); } + $this->session = null; + Signal::getInstance()->emit(strtolower($this->getAdapterType()) . "Disconnected"); } @@ -105,7 +138,8 @@ public function read(int $length = 4096): StringHelper Signal::getInstance()->emit(strtolower($this->getAdapterType()) . "DataRead", $data); - if ($data === false) { + if ($data === false || ($data === "" && feof($this->stream))) { + $this->disconnect(); throw new TransportException("connection to server '" . $this->config["host"] . ":" . $this->config["port"] . "' lost"); } @@ -129,13 +163,13 @@ public function readLine(string $token = "\n"): StringHelper while (!$line->endsWith($token)) { $this->waitForReadyRead(); - $data = @fgets($this->stream, 4096); + $data = $this->readLineChunk(); Signal::getInstance()->emit(strtolower($this->getAdapterType()) . "DataRead", $data); if ($data === false) { - if ($line->count()) { - $line->append($token); + if (feof($this->stream)) { + throw new TransportException("connection to server '" . $this->config["host"] . ":" . $this->config["port"] . "' lost"); } } else { $line->append($data); @@ -145,6 +179,21 @@ public function readLine(string $token = "\n"): StringHelper return $line->trim(); } + /** + * Reads the next available part of a line from the stream. + * + * A non-blocking SSH stream can return false after returning a partial + * line, even though the remainder of that line arrives shortly after. + * Keeping this operation separate also allows transports to specialize + * how chunks are retrieved. + * + * @return string|false + */ + protected function readLineChunk(): string|false + { + return @fgets($this->stream, 4096); + } + /** * Writes data to the stream. * @@ -157,11 +206,32 @@ public function send(string $data): void { $this->connect(); - @fwrite($this->stream, $data); + $length = strlen($data); + $written = 0; + + while ($written < $length) { + $result = $this->write(substr($data, $written)); + + if ($result === false || $result === 0) { + throw new TransportException("failed to write to server '" . $this->config["host"] . ":" . $this->config["port"] . "'"); + } + + $written += $result; + } Signal::getInstance()->emit(strtolower($this->getAdapterType()) . "DataSend", $data); } + /** + * Writes a chunk to the underlying stream. + * + * @return int|false + */ + protected function write(string $data): int|false + { + return @fwrite($this->stream, $data); + } + /** * Writes a line of data to the stream. * @@ -176,6 +246,11 @@ public function sendLine(string $data, string $separator = "\n"): void $size = strlen($data); $pack = 4096; + if ($size === 0) { + $this->send($separator); + return; + } + for ($seek = 0; $seek < $size;) { $rest = $size - $seek; $pack = min($rest, $pack); diff --git a/src/Transport/Transport.php b/src/Transport/Transport.php index a454fbb7..48949a6c 100644 --- a/src/Transport/Transport.php +++ b/src/Transport/Transport.php @@ -68,6 +68,10 @@ public function __construct(array $config) $config["blocking"] = 1; } + if (!array_key_exists("tls_verify", $config)) { + $config["tls_verify"] = 0; + } + $this->config = $config; return $this; } @@ -100,11 +104,11 @@ public function __wakeup() */ public function __destruct() { + $this->disconnect(); + if ($this->adapter instanceof Adapter) { $this->adapter->__destruct(); } - - $this->disconnect(); } /** @@ -154,9 +158,9 @@ public function getStream() * * @param string|null $key * @param mixed|null $default - * @return array|string + * @return mixed */ - public function getConfig(string $key = null, mixed $default = null): array|string|int + public function getConfig(string $key = null, mixed $default = null): mixed { if ($key !== null) { return array_key_exists($key, $this->config) ? $this->config[$key] : $default; @@ -240,16 +244,20 @@ protected function waitForReadyRead(int $time = 0): void return; } - do { - $read = [$this->stream]; - $null = null; + $read = [$this->stream]; + $null = null; + $result = @stream_select($read, $null, $null, $this->config["timeout"]); + + if ($result === false) { + throw new TransportException("unable to wait for data from server '" . $this->config["host"] . ":" . $this->config["port"] . "'"); + } - if ($time) { - Signal::getInstance() - ->emit(strtolower($this->getAdapterType()) . "WaitTimeout", $time, $this->getAdapter()); - } + if ($result === 0) { + $time = $time ?: $this->config["timeout"]; + Signal::getInstance() + ->emit(strtolower($this->getAdapterType()) . "WaitTimeout", $time, $this->getAdapter()); - $time = $time + $this->config["timeout"]; - } while (@stream_select($read, $null, $null, $this->config["timeout"]) == 0); + throw new TransportException("timed out waiting for data from server '" . $this->config["host"] . ":" . $this->config["port"] . "'"); + } } } diff --git a/src/Transport/UDP.php b/src/Transport/UDP.php index 6d825f08..5902320d 100644 --- a/src/Transport/UDP.php +++ b/src/Transport/UDP.php @@ -31,17 +31,33 @@ public function connect(): void $address = "udp://" . (str_contains($host, ":") ? "[" . $host . "]" : $host) . ":" . $port; $timeout = (int)$this->config["timeout"]; + $errno = 0; + $errstr = ''; - $this->stream = @stream_socket_client($address, $errno, $errstr, $timeout); + $this->stream = $this->openSocket($address, $errno, $errstr, $timeout); if ($this->stream === false) { - throw new TransportException(StringHelper::factory($errstr)->toUtf8()->toString(), $errno); + $message = $errstr ?: "failed to connect to server '$host:$port'"; + throw new TransportException(StringHelper::factory($message)->toUtf8()->toString(), $errno); } @stream_set_timeout($this->stream, $timeout); @stream_set_blocking($this->stream, $this->config["blocking"] ? 1 : 0); } + /** + * Opens the datagram socket. + * + * Kept separate from connect() so transports can specialize connection + * creation without duplicating configuration and stream setup. + * + * @return mixed + */ + protected function openSocket(string $address, int &$errno, string &$errstr, int $timeout): mixed + { + return @stream_socket_client($address, $errno, $errstr, $timeout); + } + /** * Disconnects from a remote server. * @@ -53,6 +69,10 @@ public function disconnect(): void return; } + if (is_resource($this->stream)) { + @fclose($this->stream); + } + $this->stream = null; Signal::getInstance()->emit(strtolower($this->getAdapterType()) . "Disconnected"); @@ -92,8 +112,22 @@ public function send(string $data): void { $this->connect(); - @stream_socket_sendto($this->stream, $data); + $written = $this->sendTo($data); + + if ($written === false || $written !== strlen($data)) { + throw new TransportException("failed to write to server '" . $this->config["host"] . ":" . $this->config["port"] . "'"); + } Signal::getInstance()->emit(strtolower($this->getAdapterType()) . "DataSend", $data); } + + /** + * Sends a datagram through the underlying stream. + * + * @return int|false + */ + protected function sendTo(string $data): int|false + { + return @stream_socket_sendto($this->stream, $data); + } } diff --git a/src/Viewer/Html.php b/src/Viewer/Html.php index 896a8abe..87df1f40 100644 --- a/src/Viewer/Html.php +++ b/src/Viewer/Html.php @@ -400,7 +400,7 @@ protected function getSuffixIconServer(): string if ($this->currObj["virtualserver_icon_id"]) { if (!$this->currObj->iconIsLocal("virtualserver_icon_id") && $this->ftclient) { if (!isset($this->cacheIcon[$this->currObj["virtualserver_icon_id"]])) { - $download = $this->currObj->transferInitDownload(rand(0x0000, 0xFFFF), 0, $this->currObj->iconGetName("virtualserver_icon_id")); + $download = $this->currObj->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $this->currObj->iconGetName("virtualserver_icon_id")); if ($this->ftclient == "data:image") { $download = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"])->download($download["ftkey"], $download["size"]); @@ -460,7 +460,7 @@ protected function getSuffixIconChannel(): string if ($this->currObj["channel_icon_id"]) { if (!$this->currObj->iconIsLocal("channel_icon_id") && $this->ftclient) { if (!isset($this->cacheIcon[$this->currObj["channel_icon_id"]])) { - $download = $this->currObj->getParent()->transferInitDownload(rand(0x0000, 0xFFFF), 0, $this->currObj->iconGetName("channel_icon_id")); + $download = $this->currObj->getParent()->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $this->currObj->iconGetName("channel_icon_id")); if ($this->ftclient == "data:image") { $download = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"])->download($download["ftkey"], $download["size"]); @@ -522,7 +522,7 @@ protected function getSuffixIconClient(): string if (!$group->iconIsLocal("iconid") && $this->ftclient) { if (!isset($this->cacheIcon[$group["iconid"]])) { - $download = $group->getParent()->transferInitDownload(rand(0x0000, 0xFFFF), 0, $group->iconGetName("iconid")); + $download = $group->getParent()->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $group->iconGetName("iconid")); if ($this->ftclient == "data:image") { $download = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"])->download($download["ftkey"], $download["size"]); @@ -546,7 +546,7 @@ protected function getSuffixIconClient(): string if ($this->currObj["client_icon_id"]) { if (!$this->currObj->iconIsLocal("client_icon_id") && $this->ftclient) { if (!isset($this->cacheIcon[$this->currObj["client_icon_id"]])) { - $download = $this->currObj->getParent()->transferInitDownload(rand(0x0000, 0xFFFF), 0, $this->currObj->iconGetName("client_icon_id")); + $download = $this->currObj->getParent()->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $this->currObj->iconGetName("client_icon_id")); if ($this->ftclient == "data:image") { $download = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"])->download($download["ftkey"], $download["size"]); @@ -611,6 +611,9 @@ protected function getImage(string $name, string $text = "", string $class = nul $src = $this->flagpath; } - return ""; + $src = htmlspecialchars($src . $name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $text = htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + return ""; } } diff --git a/src/Viewer/Json.php b/src/Viewer/Json.php index c27284e3..7bf00498 100644 --- a/src/Viewer/Json.php +++ b/src/Viewer/Json.php @@ -6,6 +6,7 @@ use PlanetTeamSpeak\TeamSpeak3Framework\Node\Channel; use PlanetTeamSpeak\TeamSpeak3Framework\Node\ChannelGroup; use PlanetTeamSpeak\TeamSpeak3Framework\Node\Client; +use PlanetTeamSpeak\TeamSpeak3Framework\Node\Host; use PlanetTeamSpeak\TeamSpeak3Framework\Node\Node; use PlanetTeamSpeak\TeamSpeak3Framework\Node\Server; use PlanetTeamSpeak\TeamSpeak3Framework\Node\ServerGroup; @@ -291,7 +292,7 @@ protected function getProps(): stdClass { $props = new stdClass(); - if (is_a($this->currObj, Node::class)) { + if (is_a($this->currObj, Host::class)) { $this->id = 0; $this->icon = 0; $props->version = $this->currObj->version("version")->toString(); diff --git a/src/Viewer/Text.php b/src/Viewer/Text.php index 922335b1..d99b86e1 100644 --- a/src/Viewer/Text.php +++ b/src/Viewer/Text.php @@ -19,6 +19,16 @@ class Text implements ViewerInterface */ protected string $pattern = "%0%1 %2\n"; + /** + * The node currently being rendered. + */ + protected ?Node $currObj = null; + + /** + * The current node's ancestor sibling state. + */ + protected array $currSib = []; + /** * Returns the code needed to display a node in a TeamSpeak 3 viewer. * diff --git a/tests/Adapter/FileTransferTest.php b/tests/Adapter/FileTransferTest.php new file mode 100644 index 00000000..8b8ed217 --- /dev/null +++ b/tests/Adapter/FileTransferTest.php @@ -0,0 +1,81 @@ + 'test', 'port' => 12345]) extends FileTransfer { + public function syn(): void + { + $this->transport = new class (['host' => 'test', 'port' => 12345]) extends Transport { + private array $chunks = ['ab', 'cde']; + + public function connect(): void + { + } + + public function disconnect(): void + { + } + + public function read(int $length = 4096): StringHelper + { + return new StringHelper(array_shift($this->chunks)); + } + + public function send(string $data): void + { + } + }; + } + + protected function init(string $ftkey): void + { + } + }; + $chunks = []; + + $transfer->downloadTo('1234567890123456', 5, static function (StringHelper $data) use (&$chunks): void { + $chunks[] = $data->toString(); + }); + + $this->assertSame(['ab', 'cde'], $chunks); + } + + public function testDownloadCollectsPartialReadsWithoutSkippingData(): void + { + $transfer = new class (['host' => 'test', 'port' => 12345]) extends FileTransfer { + public function syn(): void + { + $this->transport = new class (['host' => 'test', 'port' => 12345]) extends Transport { + private array $chunks = ['ab', 'cde']; + public function connect(): void + { + } + public function disconnect(): void + { + } + public function read(int $length = 4096): StringHelper + { + return new StringHelper(array_shift($this->chunks)); + } + public function send(string $data): void + { + } + }; + } + protected function init(string $ftkey): void + { + } + }; + + $this->assertSame('abcde', $transfer->download('1234567890123456', 5)->toString()); + } +} diff --git a/tests/Adapter/ServerQuery/EventTest.php b/tests/Adapter/ServerQuery/EventTest.php new file mode 100644 index 00000000..f4b44f45 --- /dev/null +++ b/tests/Adapter/ServerQuery/EventTest.php @@ -0,0 +1,63 @@ +assertSame('textmessage', $event->getType()->toString()); + $this->assertSame(7, $event['clid']); + $this->assertSame('Hello World', $event->getData()['msg']->toString()); + $this->assertSame('clid=7 msg=Hello\\sWorld', $event->getMessage()->toString()); + } + + public function testRejectsInvalidEventFormat(): void + { + $this->expectException(AdapterException::class); + $this->expectExceptionMessage('invalid notification event format'); + + new Event(new StringHelper('error id=0 msg=ok')); + } + + public function testRejectsEventWithoutData(): void + { + $this->expectException(AdapterException::class); + $this->expectExceptionMessage('invalid notification event data'); + + new Event(new StringHelper('notifytextmessage')); + } + + public function testRejectsUnknownOffsets(): void + { + $event = new Event(new StringHelper('notifytextmessage clid=7')); + + $this->expectException(ServerQueryException::class); + $this->expectExceptionMessage('invalid parameter'); + $event['missing']; + } + + public function testEventsAreReadOnlyButCanForgetAnOffset(): void + { + $event = new Event(new StringHelper('notifytextmessage clid=7')); + + try { + $event['clid'] = 8; + $this->fail('Expected event mutation to fail.'); + } catch (NodeException) { + $this->assertTrue(isset($event['clid'])); + } + + unset($event['clid']); + $this->assertFalse(isset($event['clid'])); + } +} diff --git a/tests/Adapter/ServerQuery/ReplyTest.php b/tests/Adapter/ServerQuery/ReplyTest.php index 78598c39..92f00576 100644 --- a/tests/Adapter/ServerQuery/ReplyTest.php +++ b/tests/Adapter/ServerQuery/ReplyTest.php @@ -75,11 +75,33 @@ public function testToString() $this->assertEquals(static::$E_SERVERLIST, (string) $reply->toString()); } + public function testToStringDoesNotChangeSubsequentParsing(): void + { + $reply = new Reply([ + new StringHelper('virtualserver_name=TeamSpeak\\sServer'), + new StringHelper(static::$S_ERROR_OK), + ]); + + $this->assertSame('virtualserver_name=TeamSpeak Server', $reply->toString()->toString()); + $this->assertSame('TeamSpeak Server', $reply->toArray()[0]['virtualserver_name']->toString()); + } + public function testToLines() { + $reply = new Reply([new StringHelper(static::$S_CLIENTLIST), new StringHelper(static::$S_ERROR_OK)]); + + $lines = $reply->toLines(); + $this->assertCount(3, $lines); + $this->assertSame('clid=1 cid=1 client_database_id=1 client_nickname=serveradmin from [::1]:59642 client_type=1', $lines[0]->toString()); } public function testToTable() { + $reply = new Reply([new StringHelper(static::$S_CLIENTLIST), new StringHelper(static::$S_ERROR_OK)]); + + $table = $reply->toTable(); + $this->assertCount(3, $table); + $this->assertSame('clid=1', $table[0][0]->toString()); + $this->assertSame('client_nickname=serveradmin from [::1]:59642', $table[0][3]->toString()); } /** @@ -106,31 +128,65 @@ public function testToArray() public function testToAssocArray() { + $reply = new Reply([new StringHelper(static::$S_CLIENTLIST), new StringHelper(static::$S_ERROR_OK)]); + + $clients = $reply->toAssocArray('clid'); + $this->assertSame(2, $clients[2]['clid']); + $this->assertSame('Unknown from [::1]:59766', $clients[3]['client_nickname']->toString()); } public function testToList() { + $reply = new Reply([new StringHelper(static::$S_CLIENTLIST_EXTENDED_SINGLE), new StringHelper(static::$S_ERROR_OK)]); + + $this->assertSame(63, $reply->toList()['clid']); } public function testToObjectArray() { + $reply = new Reply([new StringHelper(static::$S_CLIENTLIST_EXTENDED_SINGLE), new StringHelper(static::$S_ERROR_OK)]); + + $objects = $reply->toObjectArray(); + $this->assertCount(1, $objects); + $this->assertSame(63, $objects[0]->clid); } public function testGetCommandString() { + $reply = new Reply([new StringHelper(static::$S_SERVERLIST), new StringHelper(static::$S_ERROR_OK)], 'serverlist'); + + $this->assertSame('serverlist', $reply->getCommandString()->toString()); } public function testGetNotifyEvents() { + $reply = new Reply([new StringHelper(static::$S_SERVERLIST), new StringHelper(static::$S_ERROR_OK)]); + + $this->assertSame([], $reply->getNotifyEvents()); } public function testGetErrorProperty() { + $reply = new Reply([new StringHelper(static::$S_SERVERLIST), new StringHelper('error id=256 msg=failed extra_msg=details return_code=return')], '', null, false); + + $this->assertSame(256, $reply->getErrorProperty('id')); + $this->assertSame('failed', $reply->getErrorProperty('msg')->toString()); + $this->assertSame('fallback', $reply->getErrorProperty('missing', 'fallback')); } public function testFetchError() { - //$this->assertInstanceOf(\TeamSpeak3_Adapter_ServerQuery_Reply::class, $reply); - //$this->assertInternalType(PHPUnit_IsType::TYPE_INT, $reply->getErrorProperty('id')); - //$this->assertEquals(0, $reply->getErrorProperty('id')); - //$this->assertInternalType(PHPUnit_IsType::TYPE_STRING, $reply->getErrorProperty('msg')); - //$this->assertEquals('ok', $reply->getErrorProperty('msg')); + $this->expectException(ServerQueryException::class); + $this->expectExceptionCode(256); + $this->expectExceptionMessage('failed (details)'); + + new Reply([new StringHelper(static::$S_SERVERLIST), new StringHelper('error id=256 msg=failed extra_msg=details')]); } public function testFetchReply() { + $reply = new Reply([new StringHelper(static::$S_WELCOME_L1), new StringHelper(static::$S_SERVERLIST), new StringHelper(static::$S_ERROR_OK)]); + + $this->assertSame(static::$E_SERVERLIST, $reply->toString()->toString()); + } + + public function testFetchReplyRemovesGreenTeaSpeakGreeting(): void + { + $reply = new Reply([new StringHelper('Welcome to the GreenTeaSpeak ServerQuery interface.'), new StringHelper(static::$S_SERVERLIST), new StringHelper(static::$S_ERROR_OK)]); + + $this->assertSame(static::$E_SERVERLIST, $reply->toString()->toString()); } } diff --git a/tests/Adapter/ServerQueryTest.php b/tests/Adapter/ServerQueryTest.php index 5bdf3aeb..c4aabfa7 100644 --- a/tests/Adapter/ServerQueryTest.php +++ b/tests/Adapter/ServerQueryTest.php @@ -48,4 +48,56 @@ public function testLogin() $reply = $serverQuery->request($query); $this->assertEquals("ok", $reply->getErrorProperty('msg')->toString()); } + + public function testPrepareEscapesArgumentsAndBuildsListCells(): void + { + $serverQuery = $this->createMockServerQuery(); + + $this->assertSame( + 'command name=Hello\\sWorld enabled=1 disabled=0 first=one second=three|first=two', + $serverQuery->prepare('command', [ + 'name' => 'Hello World', + 'enabled' => true, + 'disabled' => false, + 'ignored' => null, + 'first' => ['one', 'two'], + 'second' => ['three', null], + ]) + ); + } + + public function testRequestRejectsBlockedCommands(): void + { + $this->expectException(ServerQueryException::class); + $this->expectExceptionCode(0x100); + $this->expectExceptionMessage('command not found'); + + $this->createMockServerQuery()->request('help'); + } + + public function testRequestTracksCountTimestampAndRuntime(): void + { + $serverQuery = $this->createMockServerQuery(); + $this->assertSame(0, $serverQuery->getQueryCount()); + $this->assertNull($serverQuery->getQueryLastTimestamp()); + + $serverQuery->request('login serveradmin secret'); + + $this->assertSame(1, $serverQuery->getQueryCount()); + $this->assertIsInt($serverQuery->getQueryLastTimestamp()); + $this->assertGreaterThanOrEqual(0, $serverQuery->getQueryRuntime()); + $this->assertSame('0.0.0.0', $serverQuery->getTransportHost()); + $this->assertSame('9987', $serverQuery->getTransportPort()); + } + + public function testAcceptsGreenTeaSpeakProtocolGreeting(): void + { + $serverQuery = new MockServerQuery([ + 'host' => '0.0.0.0', + 'port' => 9987, + 'welcome' => 'GreenTeaSpeak ServerQuery', + ]); + + $this->assertTrue($serverQuery->getTransport()->isConnected()); + } } diff --git a/tests/Exception/TeamSpeak3ExceptionTest.php b/tests/Exception/TeamSpeak3ExceptionTest.php new file mode 100644 index 00000000..b45b7fb7 --- /dev/null +++ b/tests/Exception/TeamSpeak3ExceptionTest.php @@ -0,0 +1,29 @@ +assertSame(42, $exception->getRawCode()); + $this->assertSame('original message', $exception->getRawMessage()); + $this->assertNotSame('', $exception->getSender()); + } + + public function testCustomMessagesCanBeRegisteredAndRemoved(): void + { + TeamSpeak3Exception::registerCustomMessage(4242, 'Error %code: %mesg'); + $exception = new TeamSpeak3Exception('details', 4242); + $this->assertSame('Error 4242: details', $exception->getMessage()); + TeamSpeak3Exception::unregisterCustomMessage(4242); + + $this->expectException(TeamSpeak3Exception::class); + TeamSpeak3Exception::unregisterCustomMessage(4242); + } +} diff --git a/tests/Helper/CharTest.php b/tests/Helper/CharTest.php index a45b2574..d13cb38b 100644 --- a/tests/Helper/CharTest.php +++ b/tests/Helper/CharTest.php @@ -258,6 +258,13 @@ public function testUnicode1Byte() ); } + public function testFromHexRejectsMalformedInput(): void + { + $this->expectException(HelperException::class); + + Char::fromHex('zz'); + } + /** * @throws HelperException */ diff --git a/tests/Helper/ConvertTest.php b/tests/Helper/ConvertTest.php index 147dff6a..42b0be46 100644 --- a/tests/Helper/ConvertTest.php +++ b/tests/Helper/ConvertTest.php @@ -4,9 +4,14 @@ use PHPUnit\Framework\TestCase; use PlanetTeamSpeak\TeamSpeak3Framework\Helper\Convert; +use PlanetTeamSpeak\TeamSpeak3Framework\TeamSpeak3; class ConvertTest extends TestCase { + public function setUp(): void + { + date_default_timezone_set("UTC"); + } public function testConvertBytesToHumanReadableWithFactor1000() { $output = Convert::bytes(0); @@ -177,35 +182,104 @@ public function testConvertSecondsToHumanReadable() $this->assertEquals('0D 00:01:30', $output); $this->assertIsString($output); - // @todo: Enable after ::seconds() can handle negative integers - //$output = Convert::seconds(-1); - //$this->assertEquals('-0D 00:00:01', $output); - //$this->assertInternalType(PHPUnit_IsType::TYPE_STRING, $output); + $output = Convert::seconds(-0); + $this->assertEquals('0D 00:00:00', $output); + $this->assertIsString($output); + + $output = Convert::seconds(-1); + $this->assertEquals('-0D 00:00:01', $output); + $this->assertIsString($output); + + $output = Convert::seconds(-59); + $this->assertEquals('-0D 00:00:59', $output); + $this->assertIsString($output); + + $output = Convert::seconds(-60); + $this->assertEquals('-0D 00:01:00', $output); + $this->assertIsString($output); + + $output = Convert::seconds(((59 * 60) + 59) * -1); + $this->assertEquals('-0D 00:59:59', $output); + $this->assertIsString($output); + + $output = Convert::seconds(((59 * 60) + 60) * -1); + $this->assertEquals('-0D 01:00:00', $output); + $this->assertIsString($output); + + $output = Convert::seconds( + ((23 * (60 ** 2)) + (59 * 60) + 59) * -1 + ); + $this->assertEquals('-0D 23:59:59', $output); + $this->assertIsString($output); + + $output = Convert::seconds( + ((23 * (60 ** 2)) + (59 * 60) + 60) * -1 + ); + $this->assertEquals('-1D 00:00:00', $output); + $this->assertIsString($output); + + $output = Convert::seconds( + ((47 * (60 ** 2)) + (59 * 60) + 59) * -1 + ); + $this->assertEquals('-1D 23:59:59', $output); + $this->assertIsString($output); + + $output = Convert::seconds(-90.083); + $this->assertEquals('-0D 00:01:30', $output); + $this->assertIsString($output); } public function testConvertCodecIDToHumanReadable() { - // @todo: Find logical / comprehensive test for checking codec names + foreach ([ + TeamSpeak3::CODEC_SPEEX_NARROWBAND => 'Speex Narrowband', TeamSpeak3::CODEC_SPEEX_WIDEBAND => 'Speex Wideband', + TeamSpeak3::CODEC_SPEEX_ULTRAWIDEBAND => 'Speex Ultra-Wideband', TeamSpeak3::CODEC_CELT_MONO => 'CELT Mono', + TeamSpeak3::CODEC_OPUS_VOICE => 'Opus Voice', TeamSpeak3::CODEC_OPUS_MUSIC => 'Opus Music', + ] as $codec => $name) { + $this->assertSame($name, Convert::codec($codec)); + } + $this->assertSame('Unknown', Convert::codec(-1)); } public function testConvertGroupTypeIDToHumanReadable() { - // @todo: Find logical / comprehensive test for checking codec names + foreach ([TeamSpeak3::GROUP_DBTYPE_TEMPLATE => 'Template', TeamSpeak3::GROUP_DBTYPE_REGULAR => 'Regular', TeamSpeak3::GROUP_DBTYPE_SERVERQUERY => 'ServerQuery'] as $type => $name) { + $this->assertSame($name, Convert::groupType($type)); + } + $this->assertSame('Unknown', Convert::groupType(-1)); } public function testConvertPermTypeIDToHumanReadable() { - // @todo: Find logical / comprehensive test for checking codec names + foreach ([TeamSpeak3::PERM_TYPE_SERVERGROUP => 'Server Group', TeamSpeak3::PERM_TYPE_CLIENT => 'Client', TeamSpeak3::PERM_TYPE_CHANNEL => 'Channel', TeamSpeak3::PERM_TYPE_CHANNELGROUP => 'Channel Group', TeamSpeak3::PERM_TYPE_CHANNELCLIENT => 'Channel Client'] as $type => $name) { + $this->assertSame($name, Convert::permissionType($type)); + } + $this->assertSame('Unknown', Convert::permissionType(-1)); } public function testConvertPermCategoryIDToHumanReadable() { - // @todo: Find logical / comprehensive test for checking codec names + foreach ([ + TeamSpeak3::PERM_CAT_GLOBAL => 'Global', TeamSpeak3::PERM_CAT_GLOBAL_INFORMATION => 'Global / Information', TeamSpeak3::PERM_CAT_GLOBAL_SERVER_MGMT => 'Global / Virtual Server Management', TeamSpeak3::PERM_CAT_GLOBAL_ADM_ACTIONS => 'Global / Administration', TeamSpeak3::PERM_CAT_GLOBAL_SETTINGS => 'Global / Settings', + TeamSpeak3::PERM_CAT_SERVER => 'Virtual Server', TeamSpeak3::PERM_CAT_SERVER_INFORMATION => 'Virtual Server / Information', TeamSpeak3::PERM_CAT_SERVER_ADM_ACTIONS => 'Virtual Server / Administration', TeamSpeak3::PERM_CAT_SERVER_SETTINGS => 'Virtual Server / Settings', + TeamSpeak3::PERM_CAT_CHANNEL => 'Channel', TeamSpeak3::PERM_CAT_CHANNEL_INFORMATION => 'Channel / Information', TeamSpeak3::PERM_CAT_CHANNEL_CREATE => 'Channel / Create', TeamSpeak3::PERM_CAT_CHANNEL_MODIFY => 'Channel / Modify', TeamSpeak3::PERM_CAT_CHANNEL_DELETE => 'Channel / Delete', TeamSpeak3::PERM_CAT_CHANNEL_ACCESS => 'Channel / Access', + TeamSpeak3::PERM_CAT_GROUP => 'Group', TeamSpeak3::PERM_CAT_GROUP_INFORMATION => 'Group / Information', TeamSpeak3::PERM_CAT_GROUP_CREATE => 'Group / Create', TeamSpeak3::PERM_CAT_GROUP_MODIFY => 'Group / Modify', TeamSpeak3::PERM_CAT_GROUP_DELETE => 'Group / Delete', + TeamSpeak3::PERM_CAT_CLIENT => 'Client', TeamSpeak3::PERM_CAT_CLIENT_INFORMATION => 'Client / Information', TeamSpeak3::PERM_CAT_CLIENT_ADM_ACTIONS => 'Client / Admin', TeamSpeak3::PERM_CAT_CLIENT_BASICS => 'Client / Basics', TeamSpeak3::PERM_CAT_CLIENT_MODIFY => 'Client / Modify', TeamSpeak3::PERM_CAT_FILETRANSFER => 'File Transfer', TeamSpeak3::PERM_CAT_NEEDED_MODIFY_POWER => 'Grant', + ] as $category => $name) { + $this->assertSame($name, Convert::permissionCategory($category)); + } + $this->assertSame('Unknown', Convert::permissionCategory(-1)); } public function testConvertLogLevelIDToHumanReadable() { - // @todo: Find logical / comprehensive test for checking codec names + foreach ([TeamSpeak3::LOGLEVEL_CRITICAL => 'CRITICAL', TeamSpeak3::LOGLEVEL_ERROR => 'ERROR', TeamSpeak3::LOGLEVEL_DEBUG => 'DEBUG', TeamSpeak3::LOGLEVEL_WARNING => 'WARNING', TeamSpeak3::LOGLEVEL_INFO => 'INFO'] as $level => $name) { + $this->assertSame($name, Convert::logLevel($level)); + $this->assertEquals($level, Convert::logLevel(strtolower($name))); + } + $this->assertSame('DEVELOP', Convert::logLevel(-1)); + $this->assertEquals(TeamSpeak3::LOGLEVEL_ERROR, Convert::logLevel('error')); + $this->assertEquals(TeamSpeak3::LOGLEVEL_DEVEL, Convert::logLevel('unexpected')); } public function testConvertLogEntryToArray() @@ -236,7 +310,7 @@ public function testConvertVersionToClientFormat() { $this->assertEquals( '3.0.13.6 (2016-11-08 08:48:33)', - Convert::version('3.0.13.6 [Build: 1478594913]') + Convert::version('3.0.13.6 [Build: 1478594913]')->toString() ); } diff --git a/tests/Helper/ProfilerTest.php b/tests/Helper/ProfilerTest.php new file mode 100644 index 00000000..c2cc9cd5 --- /dev/null +++ b/tests/Helper/ProfilerTest.php @@ -0,0 +1,50 @@ +assertTrue($timer->isRunning()); + $this->assertGreaterThanOrEqual(0, $timer->getRuntime()); + $this->assertTrue($timer->isRunning()); + + $timer->stop(); + $this->assertFalse($timer->isRunning()); + $this->assertGreaterThanOrEqual(0, $timer->getMemUsage()); + $this->assertGreaterThanOrEqual(0, $timer->getMemUsage(true)); + + $timer->start(); + $this->assertTrue($timer->isRunning()); + } + + public function testProfilerCreatesStartsStopsAndReturnsNamedTimers(): void + { + Profiler::init('named'); + $timer = Profiler::get('named'); + $this->assertInstanceOf(Timer::class, $timer); + + Profiler::stop('named'); + $this->assertFalse($timer->isRunning()); + Profiler::start('named'); + $this->assertTrue($timer->isRunning()); + + Profiler::stop('created-on-stop'); + $this->assertInstanceOf(Timer::class, Profiler::get('created-on-stop')); + } + + public function testProfilerRemovesTimers(): void + { + Profiler::init('removed'); + Profiler::remove('removed'); + + $property = new \ReflectionProperty(Profiler::class, 'timers'); + $this->assertArrayNotHasKey('removed', $property->getValue()); + } +} diff --git a/tests/Helper/SignalTest.php b/tests/Helper/SignalTest.php index d0ccb782..94d4f91f 100644 --- a/tests/Helper/SignalTest.php +++ b/tests/Helper/SignalTest.php @@ -31,6 +31,17 @@ public function testGetInstance() $this->assertEmpty(Signal::getInstance()->getSignals()); } + public function testClearAllHandlers(): void + { + $signal = Signal::getInstance(); + $signal->subscribe(static::$signal, static::$callback); + $signal->subscribe('anotherSignal', static::$callback); + + $signal->clearAllHandlers(); + + $this->assertSame([], $signal->getSignals()); + } + /** * @throws HelperException */ diff --git a/tests/Helper/StringTest.php b/tests/Helper/StringTest.php index df441f2d..cc235cfb 100644 --- a/tests/Helper/StringTest.php +++ b/tests/Helper/StringTest.php @@ -110,6 +110,16 @@ public function testIsInt() } } + public function testToIntSupportsValuesAboveSignedThirtyTwoBitRange(): void + { + if (PHP_INT_SIZE < 8) { + $this->markTestSkipped('Requires a 64-bit PHP integer.'); + } + + $this->assertSame(2147483648, (new StringHelper('2147483648'))->toInt()); + $this->assertSame(-1, (new StringHelper('9223372036854775808'))->toInt()); + } + public function testFactory() { $string = StringHelper::factory("hello world"); @@ -180,6 +190,8 @@ public function testToSha1() public function testIsUtf8() { + $this->assertTrue((new StringHelper('11'))->isUtf8()); + $string = new StringHelper(mb_convert_encoding("Äpfel", 'UTF-8', mb_list_encodings())); $this->assertTrue($string->isUtf8()); @@ -230,9 +242,14 @@ function ($mb_string, $item) { return $mb_string; } )); - $this->assertTrue($upperUtf8MultibyteChar->isUtf8()); + $this->assertSame( + $boundary[1][0] !== 0xF0, + $upperUtf8MultibyteChar->isUtf8() + ); } + $this->assertFalse((new StringHelper("\xC2\x80\xFF"))->isUtf8()); + foreach ($unicodeBoundariesMalformed as $boundary) { $lowerUtf8MultibyteChar = new StringHelper(array_reduce( $boundary[0], @@ -265,6 +282,23 @@ public function testToUft8() $this->assertEquals(mb_convert_encoding($notUtf8, 'UTF-8', mb_list_encodings()), $stringNotUtf8->toUtf8()->toString()); } + public function testPreventConvertIntToUtf8() + { + //first convert issue begins at int = 11 + $int = 11; + $intStringHelper = new StringHelper($int); + $this->assertEquals($int, $intStringHelper->toUtf8()->toString()); + + //last convert issue end at int = 93 + $int = 93; + $intStringHelper = new StringHelper($int); + $this->assertEquals($int, $intStringHelper->toUtf8()->toString()); + + $int = 99999; + $intStringHelper = new StringHelper($int); + $this->assertEquals($int, $intStringHelper->toUtf8()->toString()); + } + public function testToBase64() { $string = new StringHelper("Hello world!"); @@ -277,6 +311,13 @@ public function testFromBase64() $this->assertEquals("Hello world!", $string->toString()); } + public function testFromBase64RejectsMalformedInput(): void + { + $this->expectException(HelperException::class); + + StringHelper::fromBase64('not valid base64!'); + } + /** * @throws Exception */ @@ -296,6 +337,13 @@ public function testFromHex() $this->assertEquals("Hello", $string->toString()); } + public function testFromHexRejectsMalformedInput(): void + { + $this->expectException(HelperException::class); + + StringHelper::fromHex('zz'); + } + public function testTransliterate() { $utf8_accents = [ diff --git a/tests/Helper/UriTest.php b/tests/Helper/UriTest.php index 3dc3be1e..366b59fb 100644 --- a/tests/Helper/UriTest.php +++ b/tests/Helper/UriTest.php @@ -2,6 +2,7 @@ namespace PlanetTeamSpeak\TeamSpeak3Framework\Tests\Helper; +use PHPUnit\Framework\Attributes\Depends; use PHPUnit\Framework\TestCase; use PlanetTeamSpeak\TeamSpeak3Framework\Helper\Uri; use PlanetTeamSpeak\TeamSpeak3Framework\Exception\HelperException; @@ -18,6 +19,8 @@ class UriTest extends TestCase 'options' => [ 'timeout', 'blocking', + 'tls', + 'tls_verify', 'nickname', 'no_query_clients', 'use_offline_as_virtual', @@ -80,6 +83,29 @@ public function testConstructEmptyURI() new Uri(''); } + public function testParameterHelpersPreserveFalsyValues(): void + { + $_REQUEST['uri_test_zero'] = '0'; + $_SERVER['uri_test_false'] = false; + $_SESSION['uri_test_empty'] = []; + + try { + $this->assertSame('0', Uri::getUserParam('uri_test_zero', 'default')); + $this->assertFalse(Uri::getHostParam('uri_test_false', true)); + $this->assertSame([], Uri::getSessParam('uri_test_empty', ['default'])); + } finally { + unset($_REQUEST['uri_test_zero'], $_SERVER['uri_test_false'], $_SESSION['uri_test_empty']); + } + } + + public function testEncodedQueryVariableNamesAreHandledConsistently(): void + { + $uri = new Uri('serverquery://127.0.0.1:10011/?tls%5Fverify=1'); + + $this->assertTrue($uri->hasQueryVar('tls_verify')); + $this->assertSame(1, $uri->getQueryVar('tls_verify')); + } + public function testConstructInvalidScheme() { $this->expectException(HelperException::class); @@ -95,7 +121,10 @@ public function testConstructInvalidScheme() public function testParseURI() { - // @todo: No reachable path results in error. Implement if found. + $this->expectException(HelperException::class); + $this->expectExceptionMessage('invalid URI supplied'); + + new Uri('serverquery://[::1'); } /** @@ -361,10 +390,7 @@ public function testIsValid(): Uri return $uri; } - /** - * @param Uri $uri - * @depends testIsValid - */ + #[Depends('testIsValid')] public function testGetScheme(Uri $uri) { $this->assertEquals('serverquery', $uri->getScheme()); @@ -374,11 +400,7 @@ public function testGetScheme(Uri $uri) ); } - /** - * @param Uri $uri - * - * @depends testIsValid - */ + #[Depends('testIsValid')] public function testGetUser(Uri $uri) { $this->assertEquals('username', $uri->getUser()); @@ -388,11 +410,7 @@ public function testGetUser(Uri $uri) ); } - /** - * @param Uri $uri - * - * @depends testIsValid - */ + #[Depends('testIsValid')] public function testGetPass(Uri $uri) { $this->assertEquals('password', $uri->getPass()); @@ -402,11 +420,7 @@ public function testGetPass(Uri $uri) ); } - /** - * @param Uri $uri - * - * @depends testIsValid - */ + #[Depends('testIsValid')] public function testGetHost(Uri $uri) { $this->assertEquals('127.0.0.1', $uri->getHost()); @@ -416,22 +430,14 @@ public function testGetHost(Uri $uri) ); } - /** - * @param Uri $uri - * - * @depends testIsValid - */ + #[Depends('testIsValid')] public function testGetPort(Uri $uri) { $this->assertEquals(10011, $uri->getPort()); $this->assertIsInt($uri->getPort()); } - /** - * @param Uri $uri - * - * @depends testIsValid - */ + #[Depends('testIsValid')] public function testGetPath(Uri $uri) { // NOTE: getPath() is never used in framework, add tests for consistency. @@ -442,11 +448,7 @@ public function testGetPath(Uri $uri) ); } - /** - * @param Uri $uri - * - * @depends testIsValid - */ + #[Depends('testIsValid')] public function testGetQuery(Uri $uri) { $this->assertEquals( @@ -456,11 +458,7 @@ public function testGetQuery(Uri $uri) $this->assertIsArray($uri->getQuery()); } - /** - * @param Uri $uri - * - * @depends testIsValid - */ + #[Depends('testIsValid')] public function testGetFragment(Uri $uri) { $this->assertEquals('no_query_clients', $uri->getFragment()); diff --git a/tests/Node/ChannelTest.php b/tests/Node/ChannelTest.php new file mode 100644 index 00000000..dfbbfaf1 --- /dev/null +++ b/tests/Node/ChannelTest.php @@ -0,0 +1,32 @@ +createMock(Server::class); + $channel = new Channel($server, ['cid' => 1]); + $client = new Client($server, ['clid' => 2, 'cid' => 1]); + + $server->method('clientList')->willReturn([2 => $client]); + + $this->assertSame($client, $channel->clientGetById(2)); + } + + public function testSubChannelGetByIdReturnsChildFromChannelList(): void + { + $server = $this->createMock(Server::class); + $parent = new Channel($server, ['cid' => 1]); + $child = new Channel($server, ['cid' => 2, 'pid' => 1]); + $server->method('channelList')->willReturn([2 => $child]); + + $this->assertSame($child, $parent->subChannelGetById(2)); + } +} diff --git a/tests/Node/HostTest.php b/tests/Node/HostTest.php new file mode 100644 index 00000000..614da399 --- /dev/null +++ b/tests/Node/HostTest.php @@ -0,0 +1,94 @@ +createHost(); + $servers = $host->serverList(['virtualserver_status' => 'online']); + + $this->assertCount(1, $servers); + $this->assertInstanceOf(Server::class, $servers[1]); + $this->assertSame('Main Server', $host->serverGetByName('Main Server')->toString()); + $this->assertSame('offline-uid', $host->serverGetByUid('offline-uid')->getProperty('virtualserver_unique_identifier')->toString()); + $this->assertSame(9987, $host->serverGetPortById(1)); + } + + public function testCachesVersionAndWhoamiValues(): void + { + $host = $this->createHost(); + $this->assertSame('3.13.7', $host->version('version')->toString()); + $this->assertSame('Linux', $host->version('platform')->toString()); + $this->assertSame(1, $host->serverSelectedId()); + $this->assertSame(9987, $host->serverSelectedPort()); + + $host->whoamiSet('client_nickname', 'tester'); + $this->assertSame('tester', $host->whoamiGet('client_nickname')->toString()); + $this->assertSame('fallback', $host->whoamiGet('missing', 'fallback')); + } + + public function testServerLookupReportsUnknownNames(): void + { + $this->expectException(ServerQueryException::class); + $this->expectExceptionCode(0x400); + $this->createHost()->serverGetByName('missing'); + } + + public function testServerStatusAndIdentityHelpers(): void + { + $server = new Server($this->createHost(), [ + 'virtualserver_id' => 7, 'virtualserver_name' => 'Offline', 'virtualserver_status' => 'offline', + 'virtualserver_clientsonline' => 5, 'virtualserver_queryclientsonline' => 1, 'virtualserver_maxclients' => 10, + 'virtualserver_flag_password' => 0, + ]); + + $this->assertTrue($server->isOffline()); + $this->assertSame(0, $server->clientCount()); + $this->assertSame('ts3_h_s7', $server->getUniqueId()); + $this->assertSame('server_open', $server->getIcon()); + $this->assertSame('$', $server->getSymbol()); + } +} + +class FixtureServerQuery extends ServerQuery +{ + public function __construct() + { + } + + public function __destruct() + { + } + + public function request(string $cmd, bool $throw = true): Reply + { + $response = match (explode(' ', $cmd)[0]) { + 'version' => 'version=3.13.7 platform=Linux', + 'whoami' => 'virtualserver_id=1 virtualserver_port=9987 client_nickname=serveradmin', + 'serverlist' => 'virtualserver_id=1 virtualserver_port=9987 virtualserver_name=Main\\sServer virtualserver_status=online virtualserver_unique_identifier=main-uid|virtualserver_id=2 virtualserver_port=9988 virtualserver_name=Offline virtualserver_status=offline virtualserver_unique_identifier=offline-uid', + default => '', + }; + + return new Reply([new StringHelper($response), new StringHelper('error id=0 msg=ok')], $cmd, null, $throw); + } + + public function prepare(string $cmd, array $params = []): string + { + return $cmd; + } +} diff --git a/tests/Node/NodeTest.php b/tests/Node/NodeTest.php new file mode 100644 index 00000000..c0cfc13e --- /dev/null +++ b/tests/Node/NodeTest.php @@ -0,0 +1,105 @@ + 'root', 'traffic_bytes_sent' => 1024, 'connection_packets_sent' => 1234, + 'connection_packetloss_total' => '0.125', 'virtualserver_uptime' => 90, + 'virtualserver_version' => '3.0.13.6 [Build: 1478594913]', 'client_icon_id' => -1, + ]); + + $info = $node->getInfo(false, true); + $this->assertSame('1 KiB', $info['traffic_bytes_sent']); + $this->assertSame('1.234', $info['connection_packets_sent']); + $this->assertSame('12.50%', $info['connection_packetloss_total']); + $this->assertSame('0D 00:01:30', $info['virtualserver_uptime']); + $this->assertSame('4294967295', $info['client_icon_id']->toString()); + $this->assertTrue($node->iconIsLocal('client_icon_id') === false); + $this->assertSame('/icon_4294967295', $node->iconGetName('client_icon_id')->toString()); + } + + public function testImplementsArrayAccessAndRecursiveIteration(): void + { + $child = new TestNode(2, ['name' => 'child']); + $node = new TestNode(1, ['name' => 'root'], [$child]); + + $this->assertSame('root', $node['name']); + $this->assertSame(1, $node->count()); + $this->assertTrue($node->valid()); + $this->assertSame(0, $node->key()); + $this->assertSame($child, $node->current()); + $this->assertTrue($node->hasChildren() === false); + $this->assertFalse($node->hasNext()); + $node->next(); + $this->assertFalse($node->valid()); + $node->rewind(); + unset($node['name']); + $this->assertFalse(isset($node['name'])); + } + + public function testRejectsMissingPropertiesAndReadOnlyMutation(): void + { + $node = new TestNode(1, []); + + $this->expectException(NodeException::class); + $node['missing']; + } + + public function testRendersItsTreeWithTextViewer(): void + { + $node = new TestNode(1, ['name' => 'root'], [new TestNode(2, ['name' => 'child'])]); + + $this->assertSame("* root\n\\-* child\n", $node->getViewer(new Text())); + } + + public function testFilterExcludesNodesWithoutTheRequestedProperty(): void + { + $node = new TestNode(1, ['name' => 'root']); + + $this->assertSame([], $node->filterForTest([$node], ['missing' => 'value'])); + } +} + +class TestNode extends Node +{ + public function __construct(int $id, array $info, array $children = []) + { + $this->nodeId = $id; + $this->nodeInfo = $info; + $this->nodeList = $children; + } + + public function getUniqueId(): string + { + return 'test_' . $this->nodeId; + } + + public function getIcon(): string + { + return 'test'; + } + + public function getSymbol(): string + { + return '*'; + } + + public function __toString(): string + { + return $this->nodeInfo['name'] ?? 'unnamed'; + } + + public function filterForTest(array $nodes, array $rules): array + { + return $this->filterList($nodes, $rules); + } +} diff --git a/tests/Node/ServerTest.php b/tests/Node/ServerTest.php new file mode 100644 index 00000000..0e80f6c1 --- /dev/null +++ b/tests/Node/ServerTest.php @@ -0,0 +1,33 @@ +serverGroupList(); + + $this->assertSame([1, 2], array_keys($groups)); + } +} diff --git a/tests/Node/ServerViewerPermissionTest.php b/tests/Node/ServerViewerPermissionTest.php new file mode 100644 index 00000000..0f46ef50 --- /dev/null +++ b/tests/Node/ServerViewerPermissionTest.php @@ -0,0 +1,92 @@ + 1, + 'virtualserver_name' => 'Server', + ]); + $server->setChannels([ + new PermissionAwareChannel($server, ['cid' => 1, 'pid' => 0, 'channel_name' => 'Visible'], false), + new PermissionAwareChannel($server, ['cid' => 2, 'pid' => 0, 'channel_name' => 'Restricted'], true), + ]); + + $viewer = $server->getViewer(new Text()); + + $this->assertStringContainsString('Visible', $viewer); + $this->assertStringNotContainsString('Restricted', $viewer); + } + + public function testViewerDoesNotHideUnexpectedChannelErrors(): void + { + $server = new PermissionAwareServer(new Host(new HostTestFixtureQuery()), [ + 'virtualserver_id' => 1, + 'virtualserver_name' => 'Server', + ]); + $server->setChannels([ + new PermissionAwareChannel($server, ['cid' => 1, 'pid' => 0, 'channel_name' => 'Broken'], false, 0x300), + ]); + + $this->expectException(ServerQueryException::class); + $this->expectExceptionCode(0x300); + $server->getViewer(new Text()); + } +} + +class HostTestFixtureQuery extends \PlanetTeamSpeak\TeamSpeak3Framework\Adapter\ServerQuery +{ + public function __construct() + { + } + + public function __destruct() + { + } +} + +class PermissionAwareServer extends Server +{ + private array $channels = []; + + public function setChannels(array $channels): void + { + $this->channels = $channels; + } + + public function channelList(array $filter = []): array + { + return $this->channels; + } +} + +class PermissionAwareChannel extends Channel +{ + public function __construct(Server $server, array $info, private bool $restricted, private ?int $errorCode = null) + { + parent::__construct($server, $info); + } + + public function count(): int + { + if ($this->errorCode !== null) { + throw new ServerQueryException('unexpected channel failure', $this->errorCode); + } + + if ($this->restricted) { + throw new ServerQueryException('insufficient client permissions', 0xA08); + } + + return 0; + } +} diff --git a/tests/TeamSpeak3Test.php b/tests/TeamSpeak3Test.php new file mode 100644 index 00000000..40dca052 --- /dev/null +++ b/tests/TeamSpeak3Test.php @@ -0,0 +1,38 @@ +assertInstanceOf(Host::class, $node); + $this->assertSame('127.0.0.1', $node->getParent()->getTransportHost()); + $this->assertSame('10011', $node->getParent()->getTransportPort()); + } + + public function testEscapePatternsAndDumpAreAvailable(): void + { + $patterns = TeamSpeak3::getEscapePatterns(); + $this->assertArrayHasKey(' ', $patterns); + $this->assertSame('\\s', $patterns[' ']); + + $dump = TeamSpeak3::dump(['value' => ''], false); + $this->assertStringContainsString("array(1)", $dump); + $this->assertStringContainsString("", $dump); + } + + public function testTransferClientIdIsWithinProtocolRange(): void + { + $id = TeamSpeak3::generateTransferClientId(); + + $this->assertGreaterThanOrEqual(0, $id); + $this->assertLessThanOrEqual(0xFFFF, $id); + } +} diff --git a/tests/Transport/TCPTest.php b/tests/Transport/TCPTest.php index f04e3fc1..c9310915 100644 --- a/tests/Transport/TCPTest.php +++ b/tests/Transport/TCPTest.php @@ -4,13 +4,56 @@ use PHPUnit\Framework\TestCase; use PlanetTeamSpeak\TeamSpeak3Framework\Adapter\MockServerQuery; -use PlanetTeamSpeak\TeamSpeak3Framework\Adapter\ServerQuery; use PlanetTeamSpeak\TeamSpeak3Framework\Exception\ServerQueryException; use PlanetTeamSpeak\TeamSpeak3Framework\Transport\TCP; use PlanetTeamSpeak\TeamSpeak3Framework\Exception\TransportException; class TCPTest extends TestCase { + public function testReadLineWaitsForRemainingNonBlockingChunk(): void + { + $transport = new class (['host' => 'test', 'port' => 12345, 'blocking' => 0]) extends TCP { + private array $chunks = ['cldbid=42 client_nickname=Some', false, "|body\\sname=Other\n"]; + + public function setStreamForTest(): void + { + $this->stream = fopen('php://temp', 'r+'); + } + + protected function waitForReadyRead(int $time = 0): void + { + } + + protected function readLineChunk(): string|false + { + return array_shift($this->chunks); + } + }; + + $transport->setStreamForTest(); + + $this->assertSame( + 'cldbid=42 client_nickname=Some|body\\sname=Other', + $transport->readLine()->toString() + ); + } + + public function testReadLineThrowsWhenConnectionIsClosed(): void + { + $transport = new class (['host' => 'test', 'port' => 12345]) extends TCP { + public function setStreamForTest(): void + { + $this->stream = fopen('php://temp', 'r'); + } + }; + + $transport->setStreamForTest(); + + $this->expectException(TransportException::class); + $this->expectExceptionMessage("connection to server 'test:12345' lost"); + $transport->readLine(); + } + /** * @throws TransportException */ @@ -30,6 +73,9 @@ public function testConstructorNoException() $this->assertArrayHasKey('timeout', $adapter->getConfig()); $this->assertIsInt($adapter->getConfig('timeout')); + $this->assertArrayHasKey('tls_verify', $adapter->getConfig()); + $this->assertSame(0, $adapter->getConfig('tls_verify')); + $this->assertArrayHasKey('blocking', $adapter->getConfig()); $this->assertIsInt($adapter->getConfig('blocking')); } @@ -60,10 +106,12 @@ public function testGetConfig() ); $this->assertIsArray($adapter->getConfig()); - $this->assertCount(4, $adapter->getConfig()); + $this->assertCount(5, $adapter->getConfig()); $this->assertArrayHasKey('host', $adapter->getConfig()); $this->assertEquals('test', $adapter->getConfig()['host']); $this->assertEquals('test', $adapter->getConfig('host')); + $this->assertNull($adapter->getConfig('missing')); + $this->assertFalse($adapter->getConfig('missing', false)); } /** @@ -75,7 +123,7 @@ public function testSetGetAdapter() ['host' => 'test', 'port' => 12345] ); // Mocking adaptor since `stream_socket_client()` depends on running server - $adaptor = $this->createMock(ServerQuery::class); + $adaptor = $this->createMockServerQuery(); $transport->setAdapter($adaptor); $this->assertSame($adaptor, $transport->getAdapter()); @@ -136,9 +184,15 @@ public function testConnectBadHost() */ public function testConnectHostRefuseConnection() { - $transport = new TCP( - ['host' => '127.0.0.1', 'port' => 12345] - ); + $transport = new class (['host' => '127.0.0.1', 'port' => 12345]) extends TCP { + protected function openSocket(string $address, int &$errno, string &$errstr, int $timeout, array $options): mixed + { + $errno = 111; + $errstr = 'Connection refused'; + + return false; + } + }; $this->expectException(TransportException::class); $this->expectExceptionMessage('Connection refused'); $transport->connect(); @@ -168,6 +222,38 @@ public function testDisconnectNoConnection() $transport->disconnect(); } + public function testDisconnectClosesRetainedStream(): void + { + $transport = new class (['host' => 'test', 'port' => 12345]) extends TCP { + public function setStreamForTest($stream): void + { + $this->stream = $stream; + } + }; + $stream = fopen('php://temp', 'r+'); + $transport->setStreamForTest($stream); + + $transport->disconnect(); + + $this->assertFalse(is_resource($stream)); + $this->assertNull($transport->getStream()); + } + + public function testReadThrowsWhenStreamReachedEndOfFile(): void + { + $transport = new class (['host' => 'test', 'port' => 12345]) extends TCP { + public function setStreamForTest($stream): void + { + $this->stream = $stream; + } + }; + $transport->setStreamForTest(fopen('php://temp', 'r')); + + $this->expectException(TransportException::class); + $this->expectExceptionMessage("connection to server 'test:12345' lost"); + $transport->read(); + } + /** * @throws ServerQueryException * @throws TransportException @@ -225,6 +311,25 @@ public function testSendNoConnection() $transport->send('testsend'); } + public function testSendRetriesPartialWritesAndRejectsFailedWrites(): void + { + $transport = new class (['host' => 'test', 'port' => 12345]) extends TCP { + public array $writes = []; + private array $results = [2, 3]; + public function connect(): void + { + $this->stream = true; + } + protected function write(string $data): int|false + { + $this->writes[] = $data; + return array_shift($this->results); + } + }; + $transport->send('hello'); + $this->assertSame(['hello', 'llo'], $transport->writes); + } + /** * @throws ServerQueryException * @throws TransportException @@ -243,4 +348,93 @@ public function testSendLineNoConnection() } $transport->sendLine('test.sendLine'); } + + public function testNonBlockingReadTimesOut(): void + { + $transport = new class (['host' => 'test', 'port' => 12345, 'blocking' => 0, 'timeout' => 0]) extends TCP { + private $peer; + + public function connectForTest(): void + { + [$this->stream, $this->peer] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + } + + public function waitForReadForTest(): void + { + $this->waitForReadyRead(); + } + }; + + $transport->connectForTest(); + + $this->expectException(TransportException::class); + $this->expectExceptionMessage("timed out waiting for data from server 'test:12345'"); + $transport->waitForReadForTest(); + } + + public function testTlsVerificationIsDisabledByDefault(): void + { + $transport = new class (['host' => 'test', 'port' => 12345, 'tls' => 1]) extends TCP { + public array $contextOptions; + + protected function openSocket(string $address, int &$errno, string &$errstr, int $timeout, array $options): mixed + { + $this->contextOptions = $options; + return fopen('php://temp', 'r+'); + } + + protected function enableCrypto(): bool + { + return true; + } + }; + + $transport->connect(); + + $this->assertSame(['allow_self_signed' => true, 'verify_peer' => false, 'verify_peer_name' => false], $transport->contextOptions['ssl']); + } + + public function testTlsVerificationCanBeEnabled(): void + { + $transport = new class (['host' => 'test', 'port' => 12345, 'tls' => 1, 'tls_verify' => 1]) extends TCP { + public array $contextOptions; + + protected function openSocket(string $address, int &$errno, string &$errstr, int $timeout, array $options): mixed + { + $this->contextOptions = $options; + return fopen('php://temp', 'r+'); + } + + protected function enableCrypto(): bool + { + return true; + } + }; + + $transport->connect(); + + $this->assertSame(['allow_self_signed' => false, 'verify_peer' => true, 'verify_peer_name' => true], $transport->contextOptions['ssl']); + } + + public function testFailedTlsNegotiationDisconnectsTransport(): void + { + $transport = new class (['host' => 'test', 'port' => 12345, 'tls' => 1]) extends TCP { + protected function openSocket(string $address, int &$errno, string &$errstr, int $timeout, array $options): mixed + { + return fopen('php://temp', 'r+'); + } + + protected function enableCrypto(): bool + { + return false; + } + }; + + try { + $transport->connect(); + $this->fail('Expected TLS negotiation to fail.'); + } catch (TransportException) { + $this->assertNull($transport->getStream()); + } + } } diff --git a/tests/Transport/TransportTest.php b/tests/Transport/TransportTest.php index 9d29d9ff..fcd212c0 100644 --- a/tests/Transport/TransportTest.php +++ b/tests/Transport/TransportTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use PlanetTeamSpeak\TeamSpeak3Framework\Adapter\MockServerQuery; +use PlanetTeamSpeak\TeamSpeak3Framework\Helper\Signal; use PlanetTeamSpeak\TeamSpeak3Framework\Transport\Transport; class TransportTest extends TestCase @@ -29,4 +30,19 @@ public function testGetAdapterTypeReturnValue() // The Signal class combines the lowered class name with an additional string for the `emit()` function $this->assertEquals("mockserverquery", strtolower($mockServerQuery->getTransport()->getAdapterType())); } + + public function testDestructorDisconnectsBeforeDestroyingAdapter(): void + { + $mockServerQuery = $this->createMockServerQuery(); + $commands = []; + $callback = static function (string $command) use (&$commands): void { + $commands[] = $command; + }; + + Signal::getInstance()->subscribe("mockserverqueryDataSend", $callback); + $mockServerQuery->getTransport()->__destruct(); + Signal::getInstance()->unsubscribe("mockserverqueryDataSend", $callback); + + $this->assertEmpty($commands); + } } diff --git a/tests/Transport/UDPTest.php b/tests/Transport/UDPTest.php index d30a5929..8a59197e 100644 --- a/tests/Transport/UDPTest.php +++ b/tests/Transport/UDPTest.php @@ -3,12 +3,22 @@ namespace PlanetTeamSpeak\TeamSpeak3Framework\Tests\Transport; use PHPUnit\Framework\TestCase; -use PlanetTeamSpeak\TeamSpeak3Framework\Adapter\ServerQuery; +use PlanetTeamSpeak\TeamSpeak3Framework\Adapter\MockServerQuery; use PlanetTeamSpeak\TeamSpeak3Framework\Transport\UDP; use PlanetTeamSpeak\TeamSpeak3Framework\Exception\TransportException; class UDPTest extends TestCase { + private function createConnectedTransport(): UDP + { + return new class (['host' => '127.0.0.1', 'port' => 12345]) extends UDP { + protected function openSocket(string $address, int &$errno, string &$errstr, int $timeout): mixed + { + return fopen('php://temp', 'r+'); + } + }; + } + /** * @throws TransportException */ @@ -28,6 +38,9 @@ public function testConstructorNoException() $this->assertArrayHasKey('timeout', $adapter->getConfig()); $this->assertIsInt($adapter->getConfig('timeout')); + $this->assertArrayHasKey('tls_verify', $adapter->getConfig()); + $this->assertSame(0, $adapter->getConfig('tls_verify')); + $this->assertArrayHasKey('blocking', $adapter->getConfig()); $this->assertIsInt($adapter->getConfig('blocking')); } @@ -58,7 +71,7 @@ public function testGetConfig() ); $this->assertIsArray($adapter->getConfig()); - $this->assertCount(4, $adapter->getConfig()); + $this->assertCount(5, $adapter->getConfig()); $this->assertArrayHasKey('host', $adapter->getConfig()); $this->assertEquals('test', $adapter->getConfig()['host']); $this->assertEquals('test', $adapter->getConfig('host')); @@ -73,7 +86,7 @@ public function testSetGetAdapter() ['host' => 'test', 'port' => 12345] ); // Mocking adaptor since `stream_socket_client()` depends on running server - $adaptor = $this->createMock(ServerQuery::class); + $adaptor = new MockServerQuery(['host' => '0.0.0.0', 'port' => 9987]); $transport->setAdapter($adaptor); $this->assertSame($adaptor, $transport->getAdapter()); @@ -95,9 +108,7 @@ public function testGetStream() */ public function testConnect() { - $transport = new UDP( - ['host' => '127.0.0.1', 'port' => 12345] - ); + $transport = $this->createConnectedTransport(); $transport->connect(); $this->assertIsResource($transport->getStream()); } @@ -125,9 +136,7 @@ public function testConnectBadHost() */ public function testDisconnect() { - $transport = new UDP( - ['host' => '127.0.0.1', 'port' => 12345] - ); + $transport = $this->createConnectedTransport(); $transport->connect(); $this->assertIsResource($transport->getStream()); $transport->disconnect(); @@ -146,6 +155,23 @@ public function testDisconnectNoConnection() $transport->disconnect(); } + public function testDisconnectClosesRetainedStream(): void + { + $transport = new class (['host' => 'test', 'port' => 12345]) extends UDP { + public function setStreamForTest($stream): void + { + $this->stream = $stream; + } + }; + $stream = fopen('php://temp', 'r+'); + $transport->setStreamForTest($stream); + + $transport->disconnect(); + + $this->assertFalse(is_resource($stream)); + $this->assertNull($transport->getStream()); + } + /** * @throws TransportException */ @@ -181,4 +207,20 @@ public function testSendNoConnection() } $transport->send('test.send'); } + + public function testSendRejectsPartialDatagrams(): void + { + $transport = new class (['host' => 'test', 'port' => 12345]) extends UDP { + public function connect(): void + { + $this->stream = true; + } + protected function sendTo(string $data): int|false + { + return 1; + } + }; + $this->expectException(TransportException::class); + $transport->send('ab'); + } } diff --git a/tests/Viewer/ViewerTest.php b/tests/Viewer/ViewerTest.php new file mode 100644 index 00000000..5427e000 --- /dev/null +++ b/tests/Viewer/ViewerTest.php @@ -0,0 +1,148 @@ + '127.0.0.1', 'port' => 10011])); + + return new ViewerServer($host, [ + 'virtualserver_id' => 1, + 'virtualserver_name' => 'Example ', + 'virtualserver_status' => 'online', + 'virtualserver_clientsonline' => 4, + 'virtualserver_queryclientsonline' => 1, + 'virtualserver_maxclients' => 32, + 'virtualserver_uptime' => 90, + 'virtualserver_icon_id' => 100, + 'virtualserver_welcomemessage' => ' Welcome ', + 'virtualserver_hostmessage' => '', + 'virtualserver_version' => '3.0.13.6 [Build: 1478594913]', + 'virtualserver_platform' => new \PlanetTeamSpeak\TeamSpeak3Framework\Helper\StringHelper('Linux'), + 'virtualserver_flag_password' => 1, + 'virtualserver_autostart' => 1, + 'virtualserver_weblist_enabled' => 1, + 'virtualserver_ask_for_privilegekey' => 1, + ]); + } + + public function testHtmlViewerRendersEscapedServerInformationAndIcons(): void + { + $html = (new Html('icons/'))->fetchObject($this->createServer()); + + $this->assertStringContainsString("id='ts3_h_s1'", $html); + $this->assertStringContainsString('Example <Server>', $html); + $this->assertStringContainsString("icons/server_pass.png", $html); + $this->assertStringContainsString("icons/group_icon_100.png", $html); + $this->assertStringContainsString('Clients: 3/32', $html); + } + + public function testHtmlImageAttributesAreEscaped(): void + { + $viewer = new HtmlForTest("icons/'onerror='alert(1)"); + + $html = $viewer->getImageForTest("image.png' onerror='alert(2)", "title' onmouseover='alert(3)"); + + $this->assertStringNotContainsString("' onerror=", $html); + $this->assertStringNotContainsString("' onmouseover=", $html); + $this->assertStringContainsString(''', $html); + } + + public function testJsonViewerProducesStructuredServerData(): void + { + $data = []; + $viewer = new Json($data); + $this->assertSame('', $viewer->fetchObject($this->createServer())); + + $this->assertCount(1, $data); + $this->assertSame('ts3_s1', $data[0]->ident); + $this->assertSame('server', $data[0]->class); + $this->assertSame('Example ', $data[0]->name); + $this->assertSame(31, $data[0]->props->flags); + $this->assertSame('Welcome', $data[0]->props->welcmsg); + $this->assertSame('Linux', $data[0]->props->platform); + $this->assertSame('server-pass', $data[0]->image); + $this->assertJsonStringEqualsJsonString(json_encode($data), $viewer->toString()); + } + + public function testViewersRenderChannelStateAndMetadata(): void + { + $channel = new ViewerChannel($this->createServer(), [ + 'cid' => 2, 'pid' => 0, 'channel_name' => 'Music', 'channel_topic' => ' Topic ', + 'channel_codec' => 5, 'channel_codec_quality' => 10, 'channel_icon_id' => 200, + 'channel_maxclients' => 10, 'channel_maxfamilyclients' => -1, + 'total_clients' => 2, 'total_clients_family' => -1, + 'channel_flag_default' => 1, 'channel_flag_password' => 1, + 'channel_flag_permanent' => 1, 'channel_flag_semi_permanent' => 0, + 'channel_needed_talk_power' => 5, + ]); + + $html = (new Html('icons/'))->fetchObject($channel, [true]); + $this->assertStringContainsString("id='ts3_h_s1_ch2'", $html); + $this->assertStringContainsString('channel_flag_music.png', $html); + $this->assertStringContainsString('channel_flag_moderated.png', $html); + + $data = []; + (new Json($data))->fetchObject($channel, [true]); + $this->assertSame('ts3_c2', $data[0]->ident); + $this->assertSame('ts3_s1', $data[0]->parent); + $this->assertSame('Music', $data[0]->name); + $this->assertSame('Music', $data[0]->props->path); + $this->assertSame('Topic', $data[0]->props->topic); + $this->assertSame(119, $data[0]->props->flags); + } +} + +class ViewerServer extends Server +{ + public function count(): int + { + return 0; + } + + public function clientCount(): int + { + return 3; + } + + public function channelGetLevel(int $cid): int + { + return 0; + } + + public function channelGetPathway(int $cid): string + { + return 'Music'; + } + + public function channelIsSpacer(Channel $channel): bool + { + return false; + } +} + +class ViewerChannel extends Channel +{ + public function count(): int + { + return 0; + } +} + +class HtmlForTest extends Html +{ + public function getImageForTest(string $name, string $text): string + { + return $this->getImage($name, $text); + } +}