diff --git a/.gitignore b/.gitignore index 987e2a2..42ab5d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ composer.lock vendor +tests/ab/reports +reports diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..b1c7c1c --- /dev/null +++ b/.travis.yml @@ -0,0 +1,20 @@ +language: php + +php: + - 5.4 + - 5.5 + - 5.6 + - 7 + - hhvm + +before_install: + - export PATH=$HOME/.local/bin:$PATH + - pip install autobahntestsuite --user `whoami` + - pip list autobahntestsuite --user `whoami` + +before_script: + - composer install + - sh tests/ab/run_ab_tests.sh + +script: + - phpunit diff --git a/LICENSE b/LICENSE index 66857ea..7f8c128 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2011-2014 Chris Boden +Copyright (c) 2011-2016 Chris Boden Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 95e8b4f..73aac8b 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,12 @@ # RFC6455 - The WebSocket Protocol -This library is meant to be a protocol handler for the RFC6455 specification. +[![Build Status](https://travis-ci.org/ratchetphp/RFC6455.svg?branch=master)](https://travis-ci.org/ratchetphp/RFC6455) ---- +This library a protocol handler for the RFC6455 specification. +It contains components for both server and client side handshake and messaging protocol negotation. -### A rough roadmap +Aspects that are left open to interpertation in the specification are also left open in this library. +It is up to the implementation to determine how those interpertations are to be dealt with. -* v0.1 is the initial split from Ratchet/v0.3.2 as-is. In this state it currently relies on some of Ratchet's interfaces. -* v0.2 will be more framework agnostic and will not require any interfaces from Ratchet. A dependency on Guzzle (or hopefully PSR-7) may be required. -* v0.3 will look into performance tuning. No more expected exceptions. -* v0.4 extension support -* v1.0 when all the bases are covered +This library is independent, framework agnostic, and does not deal with any I/O. +HTTP upgrade negotiation integration points are handled with PSR-7 interfaces. diff --git a/composer.json b/composer.json index e8c64c4..77d9fde 100644 --- a/composer.json +++ b/composer.json @@ -1,29 +1,31 @@ { - "name": "ratchet/rfc6455" - , "type": "library" - , "description": "RFC6455 protocol handler" - , "keywords": ["WebSockets"] - , "homepage": "http://socketo.me" - , "license": "MIT" - , "authors": [ - { - "name": "Chris Boden" - , "email": "cboden@gmail.com" - , "role": "Developer" - } - ] - , "support": { - "forum": "https://groups.google.com/forum/#!forum/ratchet-php" + "name": "ratchet/rfc6455", + "type": "library", + "description": "RFC6455 WebSocket protocol handler", + "keywords": ["WebSockets", "websocket", "RFC6455"], + "homepage": "http://socketo.me", + "license": "MIT", + "authors": [{ + "name": "Chris Boden" + , "email": "cboden@gmail.com" + , "role": "Developer" + }], + "support": { + "forum": "https://groups.google.com/forum/#!forum/ratchet-php" , "issues": "https://github.com/ratchetphp/RFC6455/issues" - , "irc": "irc://irc.freenode.org/reactphp" - } - , "autoload": { + , "irc": "irc://irc.freenode.org/reactphp" + }, + "autoload": { "psr-4": { - "Ratchet\\WebSocket\\": "src" + "Ratchet\\RFC6455\\": "src" } - } - , "require": { - "php": ">=5.3.9" - , "guzzle/http": "~3.6" + }, + "require": { + "php": ">=5.4.2", + "guzzlehttp/psr7": "^1.0" + }, + "require-dev": { + "react/http": "^0.4.1", + "react/socket-client": "^0.4.3" } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..8f2e7d1 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,27 @@ + + + + + + tests + + test/ab + + + + + + + ./src/ + + + \ No newline at end of file diff --git a/src/Encoding/ToggleableValidator.php b/src/Encoding/ToggleableValidator.php deleted file mode 100644 index edf14bc..0000000 --- a/src/Encoding/ToggleableValidator.php +++ /dev/null @@ -1,31 +0,0 @@ -validator = new Validator; - $this->on = (boolean)$on; - } - - /** - * {@inheritdoc} - */ - public function checkEncoding($str, $encoding) { - if (!(boolean)$this->on) { - return true; - } - - return $this->validator->checkEncoding($str, $encoding); - } -} diff --git a/src/Encoding/Validator.php b/src/Encoding/Validator.php deleted file mode 100644 index 40de394..0000000 --- a/src/Encoding/Validator.php +++ /dev/null @@ -1,93 +0,0 @@ -hasMbString = extension_loaded('mbstring'); - $this->hasIconv = extension_loaded('iconv'); - } - - /** - * @param string $str The value to check the encoding - * @param string $against The type of encoding to check against - * @return bool - */ - public function checkEncoding($str, $against) { - if ('UTF-8' == $against) { - return $this->isUtf8($str); - } - - if ($this->hasMbString) { - return mb_check_encoding($str, $against); - } elseif ($this->hasIconv) { - return ($str == iconv($against, "{$against}//IGNORE", $str)); - } - - return true; - } - - protected function isUtf8($str) { - if ($this->hasMbString) { - if (false === mb_check_encoding($str, 'UTF-8')) { - return false; - } - } elseif ($this->hasIconv) { - if ($str != iconv('UTF-8', 'UTF-8//IGNORE', $str)) { - return false; - } - } - - $state = static::UTF8_ACCEPT; - - for ($i = 0, $len = strlen($str); $i < $len; $i++) { - $state = static::$dfa[256 + ($state << 4) + static::$dfa[ord($str[$i])]]; - - if (static::UTF8_REJECT === $state) { - return false; - } - } - - return true; - } -} diff --git a/src/Encoding/ValidatorInterface.php b/src/Encoding/ValidatorInterface.php deleted file mode 100644 index 374f220..0000000 --- a/src/Encoding/ValidatorInterface.php +++ /dev/null @@ -1,12 +0,0 @@ -verifier = new ResponseVerifier; + + $this->defaultHeader = new Request('GET', '', [ + 'Connection' => 'Upgrade' + , 'Upgrade' => 'websocket' + , 'Sec-WebSocket-Version' => $this->getVersion() + , 'User-Agent' => "RatchetRFC/0.0.0" + ]); + } + + public function generateRequest(UriInterface $uri) { + return $this->defaultHeader->withUri($uri) + ->withHeader("Sec-WebSocket-Key", $this->generateKey()); + } + + public function validateResponse(RequestInterface $request, ResponseInterface $response) { + return $this->verifier->verifyAll($request, $response); + } + + public function generateKey() { + $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwzyz1234567890+/='; + $charRange = strlen($chars) - 1; + $key = ''; + for ($i = 0; $i < 16; $i++) { + $key .= $chars[mt_rand(0, $charRange)]; + } + + return base64_encode($key); + } + + public function getVersion() { + return 13; + } +} \ No newline at end of file diff --git a/src/Handshake/NegotiatorInterface.php b/src/Handshake/NegotiatorInterface.php new file mode 100644 index 0000000..662ae95 --- /dev/null +++ b/src/Handshake/NegotiatorInterface.php @@ -0,0 +1,47 @@ +verifyMethod($request->getMethod()); $passes += (int)$this->verifyHTTPVersion($request->getProtocolVersion()); - $passes += (int)$this->verifyRequestURI($request->getPath()); - $passes += (int)$this->verifyHost((string)$request->getHeader('Host')); - $passes += (int)$this->verifyUpgradeRequest((string)$request->getHeader('Upgrade')); - $passes += (int)$this->verifyConnection((string)$request->getHeader('Connection')); - $passes += (int)$this->verifyKey((string)$request->getHeader('Sec-WebSocket-Key')); - //$passes += (int)$this->verifyVersion($headers['Sec-WebSocket-Version']); // Temporarily breaking functionality - - return (7 === $passes); + $passes += (int)$this->verifyRequestURI($request->getUri()->getPath()); + $passes += (int)$this->verifyHost($request->getHeader('Host')); + $passes += (int)$this->verifyUpgradeRequest($request->getHeader('Upgrade')); + $passes += (int)$this->verifyConnection($request->getHeader('Connection')); + $passes += (int)$this->verifyKey($request->getHeader('Sec-WebSocket-Key')); + $passes += (int)$this->verifyVersion($request->getHeader('Sec-WebSocket-Version')); + + return (8 === $passes); } /** @@ -51,7 +53,7 @@ public function verifyHTTPVersion($val) { * @return bool */ public function verifyRequestURI($val) { - if ($val[0] != '/') { + if ($val[0] !== '/') { return false; } @@ -67,60 +69,52 @@ public function verifyRequestURI($val) { } /** - * @param string|null + * @param array $hostHeader * @return bool - * @todo Find out if I can find the master socket, ensure the port is attached to header if not 80 or 443 - not sure if this is possible, as I tried to hide it * @todo Once I fix HTTP::getHeaders just verify this isn't NULL or empty...or maybe need to verify it's a valid domain??? Or should it equal $_SERVER['HOST'] ? */ - public function verifyHost($val) { - return (null !== $val); + public function verifyHost(array $hostHeader) { + return (1 === count($hostHeader)); } /** * Verify the Upgrade request to WebSockets. - * @param string $val MUST equal "websocket" + * @param array $upgradeHeader MUST equal "websocket" * @return bool */ - public function verifyUpgradeRequest($val) { - return ('websocket' === strtolower($val)); + public function verifyUpgradeRequest(array $upgradeHeader) { + return (1 === count($upgradeHeader) && 'websocket' === strtolower($upgradeHeader[0])); } /** * Verify the Connection header - * @param string $val MUST equal "Upgrade" + * @param array $connectionHeader MUST include "Upgrade" * @return bool */ - public function verifyConnection($val) { - $val = strtolower($val); - - if ('upgrade' === $val) { - return true; - } - - $vals = explode(',', str_replace(', ', ',', $val)); - - return (false !== array_search('upgrade', $vals)); + public function verifyConnection(array $connectionHeader) { + return count(array_filter($connectionHeader, function ($x) { + return 'upgrade' === strtolower($x); + })) > 0; } /** * This function verifies the nonce is valid (64 big encoded, 16 bytes random string) - * @param string|null + * @param array $keyHeader * @return bool * @todo The spec says we don't need to base64_decode - can I just check if the length is 24 and not decode? * @todo Check the spec to see what the encoding of the key could be */ - public function verifyKey($val) { - return (16 === strlen(base64_decode((string)$val))); + public function verifyKey(array $keyHeader) { + return (1 === count($keyHeader) && 16 === strlen(base64_decode($keyHeader[0]))); } /** * Verify the version passed matches this RFC - * @param string|int MUST equal 13|"13" + * @param string|int $versionHeader MUST equal 13|"13" * @return bool - * @todo Ran in to a problem here...I'm having HyBi use the RFC files, this breaks it! oops */ - public function verifyVersion($val) { - return (13 === (int)$val); + public function verifyVersion($versionHeader) { + return (1 === count($versionHeader) && static::VERSION === (int)$versionHeader[0]); } /** diff --git a/src/Handshake/ResponseVerifier.php b/src/Handshake/ResponseVerifier.php new file mode 100644 index 0000000..de03f53 --- /dev/null +++ b/src/Handshake/ResponseVerifier.php @@ -0,0 +1,52 @@ +verifyStatus($response->getStatusCode()); + $passes += (int)$this->verifyUpgrade($response->getHeader('Upgrade')); + $passes += (int)$this->verifyConnection($response->getHeader('Connection')); + $passes += (int)$this->verifySecWebSocketAccept( + $response->getHeader('Sec-WebSocket-Accept') + , $request->getHeader('Sec-WebSocket-Key') + ); + $passes += (int)$this->verifySubProtocol( + $request->getHeader('Sec-WebSocket-Protocol') + , $response->getHeader('Sec-WebSocket-Protocol') + ); + + return (5 === $passes); + } + + public function verifyStatus($status) { + return ((int)$status === 101); + } + + public function verifyUpgrade(array $upgrade) { + return (in_array('websocket', array_map('strtolower', $upgrade))); + } + + public function verifyConnection(array $connection) { + return (in_array('upgrade', array_map('strtolower', $connection))); + } + + public function verifySecWebSocketAccept($swa, $key) { + return ( + 1 === count($swa) && + 1 === count($key) && + $swa[0] === $this->sign($key[0]) + ); + } + + public function sign($key) { + return base64_encode(sha1($key . NegotiatorInterface::GUID, true)); + } + + public function verifySubProtocol(array $requestHeader, array $responseHeader) { + return 0 === count($responseHeader) || count(array_intersect($responseHeader, $requestHeader)) > 0; + } +} \ No newline at end of file diff --git a/src/Handshake/ServerNegotiator.php b/src/Handshake/ServerNegotiator.php new file mode 100644 index 0000000..f1e0ae0 --- /dev/null +++ b/src/Handshake/ServerNegotiator.php @@ -0,0 +1,125 @@ +verifier = $requestVerifier; + } + + /** + * {@inheritdoc} + */ + public function isProtocol(RequestInterface $request) { + return $this->verifier->verifyVersion($request->getHeader('Sec-WebSocket-Version')); + } + + /** + * {@inheritdoc} + */ + public function getVersionNumber() { + return RequestVerifier::VERSION; + } + + /** + * {@inheritdoc} + */ + public function handshake(RequestInterface $request) { + if (true !== $this->verifier->verifyMethod($request->getMethod())) { + return new Response(405); + } + + if (true !== $this->verifier->verifyHTTPVersion($request->getProtocolVersion())) { + return new Response(505); + } + + if (true !== $this->verifier->verifyRequestURI($request->getUri()->getPath())) { + return new Response(400); + } + + if (true !== $this->verifier->verifyHost($request->getHeader('Host'))) { + return new Response(400); + } + + if (true !== $this->verifier->verifyUpgradeRequest($request->getHeader('Upgrade'))) { + return new Response(400, [], '1.1', null, 'Upgrade header MUST be provided'); + } + + if (true !== $this->verifier->verifyConnection($request->getHeader('Connection'))) { + return new Response(400, [], '1.1', null, 'Connection header MUST be provided'); + } + + if (true !== $this->verifier->verifyKey($request->getHeader('Sec-WebSocket-Key'))) { + return new Response(400, [], '1.1', null, 'Invalid Sec-WebSocket-Key'); + } + + if (true !== $this->verifier->verifyVersion($request->getHeader('Sec-WebSocket-Version'))) { + return new Response(426, ['Sec-WebSocket-Version' => $this->getVersionNumber()]); + } + + $headers = []; + $subProtocols = $request->getHeader('Sec-WebSocket-Protocol'); + if (count($subProtocols) > 0 || (count($this->_supportedSubProtocols) > 0 && $this->_strictSubProtocols)) { + $subProtocols = array_map('trim', explode(',', implode(',', $subProtocols))); + + $match = array_reduce($subProtocols, function($accumulator, $protocol) { + return $accumulator ?: (isset($this->_supportedSubProtocols[$protocol]) ? $protocol : null); + }, null); + + if ($this->_strictSubProtocols && null === $match) { + return new Response(400, [], '1.1', null ,'No Sec-WebSocket-Protocols requested supported'); + } + + if (null !== $match) { + $headers['Sec-WebSocket-Protocol'] = $match; + } + } + + return new Response(101, array_merge($headers, [ + 'Upgrade' => 'websocket' + , 'Connection' => 'Upgrade' + , 'Sec-WebSocket-Accept' => $this->sign((string)$request->getHeader('Sec-WebSocket-Key')[0]) + , 'X-Powered-By' => 'Ratchet' + ])); + } + + /** + * Used when doing the handshake to encode the key, verifying client/server are speaking the same language + * @param string $key + * @return string + * @internal + */ + public function sign($key) { + return base64_encode(sha1($key . static::GUID, true)); + } + + function setSupportedSubProtocols(array $protocols) { + $this->_supportedSubProtocols = array_flip($protocols); + } + + /** + * If enabled and support for a subprotocol has been added handshake + * will not upgrade if a match between request and supported subprotocols + * @param boolean $enable + * @todo Consider extending this interface and moving this there. + * The spec does says the server can fail for this reason, but + * it is not a requirement. This is an implementation detail. + */ + function setStrictSubProtocolCheck($enable) { + $this->_strictSubProtocols = (boolean)$enable; + } +} diff --git a/src/Messaging/CloseFrameChecker.php b/src/Messaging/CloseFrameChecker.php new file mode 100644 index 0000000..3d800e5 --- /dev/null +++ b/src/Messaging/CloseFrameChecker.php @@ -0,0 +1,24 @@ +validCloseCodes = [ + Frame::CLOSE_NORMAL, + Frame::CLOSE_GOING_AWAY, + Frame::CLOSE_PROTOCOL, + Frame::CLOSE_BAD_DATA, + Frame::CLOSE_BAD_PAYLOAD, + Frame::CLOSE_POLICY, + Frame::CLOSE_TOO_BIG, + Frame::CLOSE_MAND_EXT, + Frame::CLOSE_SRV_ERR, + ]; + } + + public function __invoke($val) { + return ($val >= 3000 && $val <= 4999) || in_array($val, $this->validCloseCodes); + } +} diff --git a/src/Version/DataInterface.php b/src/Messaging/DataInterface.php similarity index 75% rename from src/Version/DataInterface.php rename to src/Messaging/DataInterface.php index bec1211..18aa2e3 100644 --- a/src/Version/DataInterface.php +++ b/src/Messaging/DataInterface.php @@ -1,5 +1,5 @@ $ufExceptionFactory */ - public function __construct($payload = null, $final = true, $opcode = 1) { + public function __construct($payload = null, $final = true, $opcode = 1, callable $ufExceptionFactory = null) { + $this->ufeg = $ufExceptionFactory ?: function($msg = '') { + return new \UnderflowException($msg); + }; + if (null === $payload) { return; } @@ -134,7 +143,7 @@ public function addBuffer($buf) { */ public function isFinal() { if (-1 === $this->firstByte) { - throw new \UnderflowException('Not enough bytes received to determine if this is the final frame in message'); + throw call_user_func($this->ufeg, 'Not enough bytes received to determine if this is the final frame in message'); } return 128 === ($this->firstByte & 128); @@ -146,7 +155,7 @@ public function isFinal() { */ public function getRsv1() { if (-1 === $this->firstByte) { - throw new \UnderflowException('Not enough bytes received to determine reserved bit'); + throw call_user_func($this->ufeg, 'Not enough bytes received to determine reserved bit'); } return 64 === ($this->firstByte & 64); @@ -158,7 +167,7 @@ public function getRsv1() { */ public function getRsv2() { if (-1 === $this->firstByte) { - throw new \UnderflowException('Not enough bytes received to determine reserved bit'); + throw call_user_func($this->ufeg, 'Not enough bytes received to determine reserved bit'); } return 32 === ($this->firstByte & 32); @@ -170,7 +179,7 @@ public function getRsv2() { */ public function getRsv3() { if (-1 === $this->firstByte) { - throw new \UnderflowException('Not enough bytes received to determine reserved bit'); + throw call_user_func($this->ufeg, 'Not enough bytes received to determine reserved bit'); } return 16 == ($this->firstByte & 16); @@ -181,7 +190,7 @@ public function getRsv3() { */ public function isMasked() { if (-1 === $this->secondByte) { - throw new \UnderflowException("Not enough bytes received ({$this->bytesRecvd}) to determine if mask is set"); + throw call_user_func($this->ufeg, "Not enough bytes received ({$this->bytesRecvd}) to determine if mask is set"); } return 128 === ($this->secondByte & 128); @@ -198,7 +207,7 @@ public function getMaskingKey() { $start = 1 + $this->getNumPayloadBytes(); if ($this->bytesRecvd < $start + static::MASK_LENGTH) { - throw new \UnderflowException('Not enough data buffered to calculate the masking key'); + throw call_user_func($this->ufeg, 'Not enough data buffered to calculate the masking key'); } return substr($this->data, $start, static::MASK_LENGTH); @@ -258,7 +267,7 @@ public function maskPayload($maskingKey = null) { */ public function unMaskPayload() { if (!$this->isCoalesced()) { - throw new \UnderflowException('Frame must be coalesced before applying mask'); + throw call_user_func($this->ufeg, 'Frame must be coalesced before applying mask'); } if (!$this->isMasked()) { @@ -288,12 +297,22 @@ public function unMaskPayload() { public function applyMask($maskingKey, $payload = null) { if (null === $payload) { if (!$this->isCoalesced()) { - throw new \UnderflowException('Frame must be coalesced to apply a mask'); + throw call_user_func($this->ufeg, 'Frame must be coalesced to apply a mask'); } $payload = substr($this->data, $this->getPayloadStartingByte(), $this->getPayloadLength()); } + $len = strlen($payload); + + if (0 === $len) { + return ''; + } + + return $payload ^ str_pad('', $len, $maskingKey, STR_PAD_RIGHT); + + // TODO: Remove this before publish - keeping methods here to compare performance (above is faster but need control against v0.3.3) + $applied = ''; for ($i = 0, $len = strlen($payload); $i < $len; $i++) { $applied .= $payload[$i] ^ $maskingKey[$i % static::MASK_LENGTH]; @@ -307,7 +326,7 @@ public function applyMask($maskingKey, $payload = null) { */ public function getOpcode() { if (-1 === $this->firstByte) { - throw new \UnderflowException('Not enough bytes received to determine opcode'); + throw call_user_func($this->ufeg, 'Not enough bytes received to determine opcode'); } return ($this->firstByte & ~240); @@ -320,7 +339,7 @@ public function getOpcode() { */ protected function getFirstPayloadVal() { if (-1 === $this->secondByte) { - throw new \UnderflowException('Not enough bytes received'); + throw call_user_func($this->ufeg, 'Not enough bytes received'); } return $this->secondByte & 127; @@ -332,7 +351,7 @@ protected function getFirstPayloadVal() { */ protected function getNumPayloadBits() { if (-1 === $this->secondByte) { - throw new \UnderflowException('Not enough bytes received'); + throw call_user_func($this->ufeg, 'Not enough bytes received'); } // By default 7 bits are used to describe the payload length @@ -379,7 +398,8 @@ public function getPayloadLength() { $byte_length = $this->getNumPayloadBytes(); if ($this->bytesRecvd < 1 + $byte_length) { - throw new \UnderflowException('Not enough data buffered to determine payload length'); + $this->defPayLen = -1; + throw call_user_func($this->ufeg, 'Not enough data buffered to determine payload length'); } $len = 0; @@ -406,16 +426,10 @@ public function getPayloadStartingByte() { */ public function getPayload() { if (!$this->isCoalesced()) { - throw new \UnderflowException('Can not return partial message'); + throw call_user_func($this->ufeg, 'Can not return partial message'); } - $payload = substr($this->data, $this->getPayloadStartingByte(), $this->getPayloadLength()); - - if ($this->isMasked()) { - $payload = $this->applyMask($this->getMaskingKey(), $payload); - } - - return $payload; + return $this->__toString(); } /** @@ -426,10 +440,19 @@ public function getContents() { return substr($this->data, 0, $this->getPayloadStartingByte() + $this->getPayloadLength()); } + public function __toString() { + $payload = (string)substr($this->data, $this->getPayloadStartingByte(), $this->getPayloadLength()); + + if ($this->isMasked()) { + $payload = $this->applyMask($this->getMaskingKey(), $payload); + } + + return $payload; + } + /** * Sometimes clients will concatenate more than one frame over the wire * This method will take the extra bytes off the end and return them - * @todo Consider returning new Frame * @return string */ public function extractOverflow() { diff --git a/src/Version/FrameInterface.php b/src/Messaging/FrameInterface.php similarity index 94% rename from src/Version/FrameInterface.php rename to src/Messaging/FrameInterface.php index 4eafb82..dc24091 100644 --- a/src/Version/FrameInterface.php +++ b/src/Messaging/FrameInterface.php @@ -1,5 +1,5 @@ _frames = new \SplDoublyLinkedList; } + public function getIterator() { + return $this->_frames; + } + /** * {@inheritdoc} */ @@ -35,7 +37,6 @@ public function isCoalesced() { /** * {@inheritdoc} - * @todo Also, I should perhaps check the type...control frames (ping/pong/close) are not to be considered part of a message */ public function addFrame(FrameInterface $fragment) { $this->_frames->push($fragment); @@ -79,13 +80,7 @@ public function getPayload() { throw new \UnderflowException('Message has not been put back together yet'); } - $buffer = ''; - - foreach ($this->_frames as $frame) { - $buffer .= $frame->getPayload(); - } - - return $buffer; + return $this->__toString(); } /** @@ -104,4 +99,25 @@ public function getContents() { return $buffer; } + + public function __toString() { + $buffer = ''; + + foreach ($this->_frames as $frame) { + $buffer .= $frame->getPayload(); + } + + return $buffer; + } + + /** + * @return boolean + */ + public function isBinary() { + if ($this->_frames->isEmpty()) { + throw new \UnderflowException('Not enough data has been received to determine if message is binary'); + } + + return Frame::OP_BINARY === $this->_frames->bottom()->getOpcode(); + } } diff --git a/src/Messaging/MessageBuffer.php b/src/Messaging/MessageBuffer.php new file mode 100644 index 0000000..fc304b1 --- /dev/null +++ b/src/Messaging/MessageBuffer.php @@ -0,0 +1,227 @@ +closeFrameChecker = $frameChecker; + $this->checkForMask = (bool)$expectMask; + + $this->exceptionFactory ?: $this->exceptionFactory = function($msg) { + return new \UnderflowException($msg); + }; + + $this->onMessage = $onMessage; + $this->onControl = $onControl ?: function() {}; + } + + /** + * @param string $data + * @return null + */ + public function onData($data) { + $this->messageBuffer ?: $this->messageBuffer = $this->newMessage(); + $this->frameBuffer ?: $this->frameBuffer = $this->newFrame(); + + $this->frameBuffer->addBuffer($data); + if (!$this->frameBuffer->isCoalesced()) { + return; + } + + $onMessage = $this->onMessage; + $onControl = $this->onControl; + + $this->frameBuffer = $this->frameCheck($this->frameBuffer); + + $overflow = $this->frameBuffer->extractOverflow(); + $this->frameBuffer->unMaskPayload(); + + $opcode = $this->frameBuffer->getOpcode(); + + if ($opcode > 2) { + $onControl($this->frameBuffer); + + if (Frame::OP_CLOSE === $opcode) { + return; + } + } else { + $this->messageBuffer->addFrame($this->frameBuffer); + } + + $this->frameBuffer = null; + + if ($this->messageBuffer->isCoalesced()) { + $msgCheck = $this->checkMessage($this->messageBuffer); + if (true !== $msgCheck) { + $onControl($this->newCloseFrame($msgCheck)); + } else { + $onMessage($this->messageBuffer); + } + + $this->messageBuffer = null; + } + + if (strlen($overflow) > 0) { + $this->onData($overflow); // PHP doesn't do tail recursion :( + } + } + + /** + * Check a frame to be added to the current message buffer + * @param \Ratchet\RFC6455\Messaging\FrameInterface|FrameInterface $frame + * @return \Ratchet\RFC6455\Messaging\FrameInterface|FrameInterface + */ + public function frameCheck(FrameInterface $frame) { + if (false !== $frame->getRsv1() || + false !== $frame->getRsv2() || + false !== $frame->getRsv3() + ) { + return $this->newCloseFrame(Frame::CLOSE_PROTOCOL); + } + + if ($this->checkForMask && !$frame->isMasked()) { + return $this->newCloseFrame(Frame::CLOSE_PROTOCOL); + } + + $opcode = $frame->getOpcode(); + + if ($opcode > 2) { + if ($frame->getPayloadLength() > 125 || !$frame->isFinal()) { + return $this->newCloseFrame(Frame::CLOSE_PROTOCOL); + } + + switch ($opcode) { + case Frame::OP_CLOSE: + $closeCode = 0; + + $bin = $frame->getPayload(); + + if (empty($bin)) { + return $this->newCloseFrame(Frame::CLOSE_NORMAL); + } + + if (strlen($bin) == 1) { + return $this->newCloseFrame(Frame::CLOSE_PROTOCOL); + } + + if (strlen($bin) >= 2) { + list($closeCode) = array_merge(unpack('n*', substr($bin, 0, 2))); + } + + $checker = $this->closeFrameChecker; + if (!$checker($closeCode)) { + return $this->newCloseFrame(Frame::CLOSE_PROTOCOL); + } + + if (!$this->checkUtf8(substr($bin, 2))) { + return $this->newCloseFrame(Frame::CLOSE_BAD_PAYLOAD); + } + + return $this->newCloseFrame(Frame::CLOSE_NORMAL); + break; + case Frame::OP_PING: + case Frame::OP_PONG: + break; + default: + return $this->newCloseFrame(Frame::CLOSE_PROTOCOL); + break; + } + + return $frame; + } + + if (Frame::OP_CONTINUE == $frame->getOpcode() && 0 == count($this->messageBuffer)) { + return $this->newCloseFrame(Frame::CLOSE_PROTOCOL); + } + + if (count($this->messageBuffer) > 0 && Frame::OP_CONTINUE != $frame->getOpcode()) { + return $this->newCloseFrame(Frame::CLOSE_PROTOCOL); + } + + return $frame; + } + + /** + * Determine if a message is valid + * @param \Ratchet\RFC6455\Messaging\MessageInterface + * @return bool|int true if valid - false if incomplete - int of recommended close code + */ + public function checkMessage(MessageInterface $message) { + if (!$message->isBinary()) { + if (!$this->checkUtf8($message->getPayload())) { + return Frame::CLOSE_BAD_PAYLOAD; + } + } + + return true; + } + + private function checkUtf8($string) { + if (extension_loaded('mbstring')) { + return mb_check_encoding($string, 'UTF-8'); + } + + return preg_match('//u', $string); + } + + /** + * @return \Ratchet\RFC6455\Messaging\MessageInterface + */ + public function newMessage() { + return new Message; + } + + /** + * @param string|null $payload + * @param bool|null $final + * @param int|null $opcode + * @return \Ratchet\RFC6455\Messaging\FrameInterface + */ + public function newFrame($payload = null, $final = null, $opcode = null) { + return new Frame($payload, $final, $opcode, $this->exceptionFactory); + } + + public function newCloseFrame($code) { + return $this->newFrame(pack('n', $code), true, Frame::OP_CLOSE); + } +} diff --git a/src/Version/MessageInterface.php b/src/Messaging/MessageInterface.php similarity index 54% rename from src/Version/MessageInterface.php rename to src/Messaging/MessageInterface.php index 476c091..fd7212e 100644 --- a/src/Version/MessageInterface.php +++ b/src/Messaging/MessageInterface.php @@ -1,7 +1,7 @@ _verifier = new HandshakeVerifier; - $this->setCloseCodes(); - - if (null === $validator) { - $validator = new Validator; - } - - $this->validator = $validator; - } - - /** - * {@inheritdoc} - */ - public function isProtocol(RequestInterface $request) { - $version = (int)(string)$request->getHeader('Sec-WebSocket-Version'); - - return ($this->getVersionNumber() === $version); - } - - /** - * {@inheritdoc} - */ - public function getVersionNumber() { - return 13; - } - - /** - * {@inheritdoc} - */ - public function handshake(RequestInterface $request) { - if (true !== $this->_verifier->verifyAll($request)) { - return new Response(400); - } - - return new Response(101, array( - 'Upgrade' => 'websocket' - , 'Connection' => 'Upgrade' - , 'Sec-WebSocket-Accept' => $this->sign((string)$request->getHeader('Sec-WebSocket-Key')) - )); - } - - /** - * @param \Ratchet\ConnectionInterface $conn - * @param \Ratchet\MessageInterface $coalescedCallback - * @return \Ratchet\WebSocket\Version\RFC6455\Connection - */ - public function upgradeConnection(ConnectionInterface $conn, MessageInterface $coalescedCallback) { - $upgraded = new Connection($conn); - - if (!isset($upgraded->WebSocket)) { - $upgraded->WebSocket = new \StdClass; - } - - $upgraded->WebSocket->coalescedCallback = $coalescedCallback; - - return $upgraded; - } - - /** - * @param \Ratchet\WebSocket\Version\RFC6455\Connection $from - * @param string $data - */ - public function onMessage(ConnectionInterface $from, $data) { - $overflow = ''; - - if (!isset($from->WebSocket->message)) { - $from->WebSocket->message = $this->newMessage(); - } - - // There is a frame fragment attached to the connection, add to it - if (!isset($from->WebSocket->frame)) { - $from->WebSocket->frame = $this->newFrame(); - } - - $from->WebSocket->frame->addBuffer($data); - if ($from->WebSocket->frame->isCoalesced()) { - $frame = $from->WebSocket->frame; - - if (false !== $frame->getRsv1() || - false !== $frame->getRsv2() || - false !== $frame->getRsv3() - ) { - return $from->close($frame::CLOSE_PROTOCOL); - } - - if (!$frame->isMasked()) { - return $from->close($frame::CLOSE_PROTOCOL); - } - - $opcode = $frame->getOpcode(); - - if ($opcode > 2) { - if ($frame->getPayloadLength() > 125 || !$frame->isFinal()) { - return $from->close($frame::CLOSE_PROTOCOL); - } - - switch ($opcode) { - case $frame::OP_CLOSE: - $closeCode = 0; - - $bin = $frame->getPayload(); - - if (empty($bin)) { - return $from->close(); - } - - if (strlen($bin) >= 2) { - list($closeCode) = array_merge(unpack('n*', substr($bin, 0, 2))); - } - - if (!$this->isValidCloseCode($closeCode)) { - return $from->close($frame::CLOSE_PROTOCOL); - } - - if (!$this->validator->checkEncoding(substr($bin, 2), 'UTF-8')) { - return $from->close($frame::CLOSE_BAD_PAYLOAD); - } - - return $from->close($frame); - break; - case $frame::OP_PING: - $from->send($this->newFrame($frame->getPayload(), true, $frame::OP_PONG)); - break; - case $frame::OP_PONG: - break; - default: - return $from->close($frame::CLOSE_PROTOCOL); - break; - } - - $overflow = $from->WebSocket->frame->extractOverflow(); - - unset($from->WebSocket->frame, $frame, $opcode); - - if (strlen($overflow) > 0) { - $this->onMessage($from, $overflow); - } - - return; - } - - $overflow = $from->WebSocket->frame->extractOverflow(); - - if ($frame::OP_CONTINUE == $frame->getOpcode() && 0 == count($from->WebSocket->message)) { - return $from->close($frame::CLOSE_PROTOCOL); - } - - if (count($from->WebSocket->message) > 0 && $frame::OP_CONTINUE != $frame->getOpcode()) { - return $from->close($frame::CLOSE_PROTOCOL); - } - - $from->WebSocket->message->addFrame($from->WebSocket->frame); - unset($from->WebSocket->frame); - } - - if ($from->WebSocket->message->isCoalesced()) { - $parsed = $from->WebSocket->message->getPayload(); - unset($from->WebSocket->message); - - if (!$this->validator->checkEncoding($parsed, 'UTF-8')) { - return $from->close(Frame::CLOSE_BAD_PAYLOAD); - } - - $from->WebSocket->coalescedCallback->onMessage($from, $parsed); - } - - if (strlen($overflow) > 0) { - $this->onMessage($from, $overflow); - } - } - - /** - * @return RFC6455\Message - */ - public function newMessage() { - return new Message; - } - - /** - * @param string|null $payload - * @param bool|null $final - * @param int|null $opcode - * @return RFC6455\Frame - */ - public function newFrame($payload = null, $final = null, $opcode = null) { - return new Frame($payload, $final, $opcode); - } - - /** - * Used when doing the handshake to encode the key, verifying client/server are speaking the same language - * @param string $key - * @return string - * @internal - */ - public function sign($key) { - return base64_encode(sha1($key . static::GUID, true)); - } - - /** - * Determine if a close code is valid - * @param int|string - * @return bool - */ - public function isValidCloseCode($val) { - if (array_key_exists($val, $this->closeCodes)) { - return true; - } - - if ($val >= 3000 && $val <= 4999) { - return true; - } - - return false; - } - - /** - * Creates a private lookup of valid, private close codes - */ - protected function setCloseCodes() { - $this->closeCodes[Frame::CLOSE_NORMAL] = true; - $this->closeCodes[Frame::CLOSE_GOING_AWAY] = true; - $this->closeCodes[Frame::CLOSE_PROTOCOL] = true; - $this->closeCodes[Frame::CLOSE_BAD_DATA] = true; - //$this->closeCodes[Frame::CLOSE_NO_STATUS] = true; - //$this->closeCodes[Frame::CLOSE_ABNORMAL] = true; - $this->closeCodes[Frame::CLOSE_BAD_PAYLOAD] = true; - $this->closeCodes[Frame::CLOSE_POLICY] = true; - $this->closeCodes[Frame::CLOSE_TOO_BIG] = true; - $this->closeCodes[Frame::CLOSE_MAND_EXT] = true; - $this->closeCodes[Frame::CLOSE_SRV_ERR] = true; - //$this->closeCodes[Frame::CLOSE_TLS] = true; - } -} diff --git a/src/Version/RFC6455/Connection.php b/src/Version/RFC6455/Connection.php deleted file mode 100644 index a17e382..0000000 --- a/src/Version/RFC6455/Connection.php +++ /dev/null @@ -1,44 +0,0 @@ -WebSocket->closing) { - if (!($msg instanceof DataInterface)) { - $msg = new Frame($msg); - } - - $this->getConnection()->send($msg->getContents()); - } - - return $this; - } - - /** - * {@inheritdoc} - */ - public function close($code = 1000) { - if ($this->WebSocket->closing) { - return; - } - - if ($code instanceof DataInterface) { - $this->send($code); - } else { - $this->send(new Frame(pack('n', $code), true, Frame::OP_CLOSE)); - } - - $this->getConnection()->close(); - - $this->WebSocket->closing = true; - } -} diff --git a/src/Version/VersionInterface.php b/src/Version/VersionInterface.php deleted file mode 100644 index 5bbe534..0000000 --- a/src/Version/VersionInterface.php +++ /dev/null @@ -1,57 +0,0 @@ -markTestSkipped('Autobahn TestSuite results not found'); + } + + $resultsJson = file_get_contents($fileName); + $results = json_decode($resultsJson); + $agentName = array_keys(get_object_vars($results))[0]; + + foreach ($results->$agentName as $name => $result) { + if ($result->behavior === "INFORMATIONAL") { + continue; + } + + $this->assertTrue(in_array($result->behavior, ["OK", "NON-STRICT"]), "Autobahn test case " . $name . " in " . $fileName); + } + } + + public function testAutobahnClientResults() { + $this->verifyAutobahnResults(__DIR__ . '/ab/reports/clients/index.json'); + } + + public function testAutobahnServerResults() { + $this->verifyAutobahnResults(__DIR__ . '/ab/reports/servers/index.json'); + } +} diff --git a/tests/ab/clientRunner.php b/tests/ab/clientRunner.php new file mode 100644 index 0000000..0c5578a --- /dev/null +++ b/tests/ab/clientRunner.php @@ -0,0 +1,228 @@ +createCached('8.8.8.8', $loop); + +$factory = new \React\SocketClient\Connector($loop, $dnsResolver); + +function echoStreamerFactory($conn) +{ + return new \Ratchet\RFC6455\Messaging\MessageBuffer( + new \Ratchet\RFC6455\Messaging\CloseFrameChecker, + function (\Ratchet\RFC6455\Messaging\MessageInterface $msg) use ($conn) { + /** @var Frame $frame */ + foreach ($msg as $frame) { + $frame->maskPayload(); + } + $conn->write($msg->getContents()); + }, + function (\Ratchet\RFC6455\Messaging\FrameInterface $frame) use ($conn) { + switch ($frame->getOpcode()) { + case Frame::OP_PING: + return $conn->write((new Frame($frame->getPayload(), true, Frame::OP_PONG))->maskPayload()->getContents()); + break; + case Frame::OP_CLOSE: + return $conn->end((new Frame($frame->getPayload(), true, Frame::OP_CLOSE))->maskPayload()->getContents()); + break; + } + }, + false + ); +} + +function getTestCases() { + global $factory; + global $testServer; + + $deferred = new Deferred(); + + $factory->create($testServer, 9001)->then(function (\React\Stream\Stream $stream) use ($deferred) { + $cn = new \Ratchet\RFC6455\Handshake\ClientNegotiator(); + $cnRequest = $cn->generateRequest(new Uri('ws://127.0.0.1:9001/getCaseCount')); + + $rawResponse = ""; + $response = null; + + /** @var \Ratchet\RFC6455\Messaging\Streaming\MessageBuffer $ms */ + $ms = null; + + $stream->on('data', function ($data) use ($stream, &$rawResponse, &$response, &$ms, $cn, $deferred, &$context, $cnRequest) { + if ($response === null) { + $rawResponse .= $data; + $pos = strpos($rawResponse, "\r\n\r\n"); + if ($pos) { + $data = substr($rawResponse, $pos + 4); + $rawResponse = substr($rawResponse, 0, $pos + 4); + $response = \GuzzleHttp\Psr7\parse_response($rawResponse); + + if (!$cn->validateResponse($cnRequest, $response)) { + $stream->end(); + $deferred->reject(); + } else { + $ms = new \Ratchet\RFC6455\Messaging\MessageBuffer( + new \Ratchet\RFC6455\Messaging\CloseFrameChecker, + function (\Ratchet\RFC6455\Messaging\MessageInterface $msg) use ($deferred, $stream) { + $deferred->resolve($msg->getPayload()); + $stream->close(); + }, + null, + false + ); + } + } + } + + // feed the message streamer + if ($ms) { + $ms->onData($data); + } + }); + + $stream->write(\GuzzleHttp\Psr7\str($cnRequest)); + }); + + return $deferred->promise(); +} + +function runTest($case) +{ + global $factory; + global $testServer; + + $casePath = "/runCase?case={$case}&agent=" . AGENT; + + $deferred = new Deferred(); + + $factory->create($testServer, 9001)->then(function (\React\Stream\Stream $stream) use ($deferred, $casePath, $case) { + $cn = new \Ratchet\RFC6455\Handshake\ClientNegotiator(); + $cnRequest = $cn->generateRequest(new Uri('ws://127.0.0.1:9001' . $casePath)); + + $rawResponse = ""; + $response = null; + + $ms = null; + + $stream->on('data', function ($data) use ($stream, &$rawResponse, &$response, &$ms, $cn, $deferred, &$context, $cnRequest) { + if ($response === null) { + $rawResponse .= $data; + $pos = strpos($rawResponse, "\r\n\r\n"); + if ($pos) { + $data = substr($rawResponse, $pos + 4); + $rawResponse = substr($rawResponse, 0, $pos + 4); + $response = \GuzzleHttp\Psr7\parse_response($rawResponse); + + if (!$cn->validateResponse($cnRequest, $response)) { + $stream->end(); + $deferred->reject(); + } else { + $ms = echoStreamerFactory($stream); + } + } + } + + // feed the message streamer + if ($ms) { + $ms->onData($data); + } + }); + + $stream->on('close', function () use ($deferred) { + $deferred->resolve(); + }); + + $stream->write(\GuzzleHttp\Psr7\str($cnRequest)); + }); + + return $deferred->promise(); +} + +function createReport() { + global $factory; + global $testServer; + + $deferred = new Deferred(); + + $factory->create($testServer, 9001)->then(function (\React\Stream\Stream $stream) use ($deferred) { + $reportPath = "/updateReports?agent=" . AGENT . "&shutdownOnComplete=true"; + $cn = new \Ratchet\RFC6455\Handshake\ClientNegotiator(); + $cnRequest = $cn->generateRequest(new Uri('ws://127.0.0.1:9001' . $reportPath)); + + $rawResponse = ""; + $response = null; + + /** @var \Ratchet\RFC6455\Messaging\MessageBuffer $ms */ + $ms = null; + + $stream->on('data', function ($data) use ($stream, &$rawResponse, &$response, &$ms, $cn, $deferred, &$context, $cnRequest) { + if ($response === null) { + $rawResponse .= $data; + $pos = strpos($rawResponse, "\r\n\r\n"); + if ($pos) { + $data = substr($rawResponse, $pos + 4); + $rawResponse = substr($rawResponse, 0, $pos + 4); + $response = \GuzzleHttp\Psr7\parse_response($rawResponse); + + if (!$cn->validateResponse($cnRequest, $response)) { + $stream->end(); + $deferred->reject(); + } else { + $ms = new \Ratchet\RFC6455\Messaging\MessageBuffer( + new \Ratchet\RFC6455\Messaging\CloseFrameChecker, + function (\Ratchet\RFC6455\Messaging\MessageInterface $msg) use ($deferred, $stream) { + $deferred->resolve($msg->getPayload()); + $stream->close(); + }, + null, + false + ); + } + } + } + + // feed the message streamer + if ($ms) { + $ms->onData($data); + } + }); + + $stream->write(\GuzzleHttp\Psr7\str($cnRequest)); + }); + + return $deferred->promise(); +} + + +$testPromises = []; + +getTestCases()->then(function ($count) use ($loop) { + $allDeferred = new Deferred(); + + $runNextCase = function () use (&$i, &$runNextCase, $count, $allDeferred) { + $i++; + if ($i > $count) { + $allDeferred->resolve(); + return; + } + runTest($i)->then($runNextCase); + }; + + $i = 0; + $runNextCase(); + + $allDeferred->promise()->then(function () { + createReport(); + }); +}); + +$loop->run(); diff --git a/tests/ab/fuzzingclient.json b/tests/ab/fuzzingclient.json new file mode 100644 index 0000000..d2fd0d0 --- /dev/null +++ b/tests/ab/fuzzingclient.json @@ -0,0 +1,14 @@ +{ + "options": { + "failByDrop": false + } + , "outdir": "./reports/servers" + , "servers": [{ + "agent": "RatchetRFC/0.1.0" + , "url": "ws://localhost:9001" + , "options": {"version": 18} + }] + , "cases": ["*"] + , "exclude-cases": ["6.4.*", "12.*","13.*"] + , "exclude-agent-cases": {} +} diff --git a/tests/ab/fuzzingserver.json b/tests/ab/fuzzingserver.json new file mode 100644 index 0000000..0422560 --- /dev/null +++ b/tests/ab/fuzzingserver.json @@ -0,0 +1,10 @@ +{ + "url": "ws://127.0.0.1:9001" + , "options": { + "failByDrop": false + } + , "outdir": "./reports/clients" + , "cases": ["*"] + , "exclude-cases": ["6.4.*", "12.*", "13.*"] + , "exclude-agent-cases": {} +} diff --git a/tests/ab/run_ab_tests.sh b/tests/ab/run_ab_tests.sh new file mode 100644 index 0000000..8fa9ced --- /dev/null +++ b/tests/ab/run_ab_tests.sh @@ -0,0 +1,11 @@ +cd tests/ab + +wstest -m fuzzingserver -s fuzzingserver.json & +sleep 5 +php clientRunner.php + +sleep 2 + +php startServer.php & +sleep 3 +wstest -m fuzzingclient -s fuzzingclient.json diff --git a/tests/ab/startServer.php b/tests/ab/startServer.php new file mode 100644 index 0000000..b256ec2 --- /dev/null +++ b/tests/ab/startServer.php @@ -0,0 +1,55 @@ +on('request', function (\React\Http\Request $request, \React\Http\Response $response) use ($negotiator, $closeFrameChecker, $uException) { + $psrRequest = new \GuzzleHttp\Psr7\Request($request->getMethod(), $request->getPath(), $request->getHeaders()); + + $negotiatorResponse = $negotiator->handshake($psrRequest); + + $response->writeHead( + $negotiatorResponse->getStatusCode(), + array_merge( + $negotiatorResponse->getHeaders(), + ["Content-Length" => "0"] + ) + ); + + if ($negotiatorResponse->getStatusCode() !== 101) { + $response->end(); + return; + } + + $parser = new \Ratchet\RFC6455\Messaging\MessageBuffer($closeFrameChecker, function(MessageInterface $message) use ($response) { + $response->write($message->getContents()); + }, function(FrameInterface $frame) use ($response, &$parser) { + switch ($frame->getOpCode()) { + case Frame::OP_CLOSE: + $response->end($frame->getContents()); + break; + case Frame::OP_PING: + $response->write($parser->newFrame($frame->getPayload(), true, Frame::OP_PONG)->getContents()); + break; + } + }, true, function() use ($uException) { + return $uException; + }); + + $request->on('data', [$parser, 'onData']); +}); + +$socket->listen(9001, '0.0.0.0'); +$loop->run(); diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..511b041 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,19 @@ +addPsr4('Ratchet\\RFC6455\\Test\\', __DIR__); + break; + } +} diff --git a/tests/unit/Handshake/RequestVerifierTest.php b/tests/unit/Handshake/RequestVerifierTest.php new file mode 100644 index 0000000..e0569fd --- /dev/null +++ b/tests/unit/Handshake/RequestVerifierTest.php @@ -0,0 +1,172 @@ +_v = new RequestVerifier(); + } + + public static function methodProvider() { + return array( + array(true, 'GET'), + array(true, 'get'), + array(true, 'Get'), + array(false, 'POST'), + array(false, 'DELETE'), + array(false, 'PUT'), + array(false, 'PATCH') + ); + } + /** + * @dataProvider methodProvider + */ + public function testMethodMustBeGet($result, $in) { + $this->assertEquals($result, $this->_v->verifyMethod($in)); + } + + public static function httpVersionProvider() { + return array( + array(true, 1.1), + array(true, '1.1'), + array(true, 1.2), + array(true, '1.2'), + array(true, 2), + array(true, '2'), + array(true, '2.0'), + array(false, '1.0'), + array(false, 1), + array(false, '0.9'), + array(false, ''), + array(false, 'hello') + ); + } + + /** + * @dataProvider httpVersionProvider + */ + public function testHttpVersionIsAtLeast1Point1($expected, $in) { + $this->assertEquals($expected, $this->_v->verifyHTTPVersion($in)); + } + + public static function uRIProvider() { + return array( + array(true, '/chat'), + array(true, '/hello/world?key=val'), + array(false, '/chat#bad'), + array(false, 'nope'), + array(false, '/ ಠ_ಠ '), + array(false, '/✖') + ); + } + + /** + * @dataProvider URIProvider + */ + public function testRequestUri($expected, $in) { + $this->assertEquals($expected, $this->_v->verifyRequestURI($in)); + } + + public static function hostProvider() { + return array( + array(true, ['server.example.com']), + array(false, []) + ); + } + + /** + * @dataProvider HostProvider + */ + public function testVerifyHostIsSet($expected, $in) { + $this->assertEquals($expected, $this->_v->verifyHost($in)); + } + + public static function upgradeProvider() { + return array( + array(true, ['websocket']), + array(true, ['Websocket']), + array(true, ['webSocket']), + array(false, []), + array(false, ['']) + ); + } + + /** + * @dataProvider upgradeProvider + */ + public function testVerifyUpgradeIsWebSocket($expected, $val) { + $this->assertEquals($expected, $this->_v->verifyUpgradeRequest($val)); + } + + public static function connectionProvider() { + return array( + array(true, ['Upgrade']), + array(true, ['upgrade']), + array(true, ['keep-alive', 'Upgrade']), + array(true, ['Upgrade', 'keep-alive']), + array(true, ['keep-alive', 'Upgrade', 'something']), + array(false, ['']), + array(false, []) + ); + } + + /** + * @dataProvider connectionProvider + */ + public function testConnectionHeaderVerification($expected, $val) { + $this->assertEquals($expected, $this->_v->verifyConnection($val)); + } + + public static function keyProvider() { + return array( + array(true, ['hkfa1L7uwN6DCo4IS3iWAw==']), + array(true, ['765vVoQpKSGJwPzJIMM2GA==']), + array(true, ['AQIDBAUGBwgJCgsMDQ4PEC==']), + array(true, ['axa2B/Yz2CdpfQAY2Q5P7w==']), + array(false, [0]), + array(false, ['Hello World']), + array(false, ['1234567890123456']), + array(false, ['123456789012345678901234']), + array(true, [base64_encode('UTF8allthngs+✓')]), + array(true, ['dGhlIHNhbXBsZSBub25jZQ==']), + array(false, []), + array(false, ['dGhlIHNhbXBsZSBub25jZQ==', 'Some other value']), + array(false, ['Some other value', 'dGhlIHNhbXBsZSBub25jZQ==']) + ); + } + + /** + * @dataProvider keyProvider + */ + public function testKeyIsBase64Encoded16BitNonce($expected, $val) { + $this->assertEquals($expected, $this->_v->verifyKey($val)); + } + + public static function versionProvider() { + return array( + array(true, [13]), + array(true, ['13']), + array(false, [12]), + array(false, [14]), + array(false, ['14']), + array(false, ['hi']), + array(false, ['']), + array(false, []) + ); + } + + /** + * @dataProvider versionProvider + */ + public function testVersionEquals13($expected, $in) { + $this->assertEquals($expected, $this->_v->verifyVersion($in)); + } +} \ No newline at end of file diff --git a/tests/unit/Handshake/ResponseVerifierTest.php b/tests/unit/Handshake/ResponseVerifierTest.php new file mode 100644 index 0000000..312930e --- /dev/null +++ b/tests/unit/Handshake/ResponseVerifierTest.php @@ -0,0 +1,34 @@ +_v = new ResponseVerifier; + } + + public static function subProtocolsProvider() { + return [ + [true, ['a'], ['a']] + , [true, ['b', 'a'], ['c', 'd', 'a']] + , [false, ['a', 'b', 'c'], ['d']] + , [true, [], []] + , [true, ['a', 'b'], []] + ]; + } + + /** + * @dataProvider subProtocolsProvider + */ + public function testVerifySubProtocol($expected, $response, $request) { + $this->assertEquals($expected, $this->_v->verifySubProtocol($response, $request)); + } +} diff --git a/tests/unit/Messaging/FrameTest.php b/tests/unit/Messaging/FrameTest.php new file mode 100644 index 0000000..b73f600 --- /dev/null +++ b/tests/unit/Messaging/FrameTest.php @@ -0,0 +1,501 @@ +_frame = new Frame; + } + + /** + * Encode the fake binary string to send over the wire + * @param string of 1's and 0's + * @return string + */ + public static function encode($in) { + if (strlen($in) > 8) { + $out = ''; + while (strlen($in) >= 8) { + $out .= static::encode(substr($in, 0, 8)); + $in = substr($in, 8); + } + return $out; + } + return chr(bindec($in)); + } + + /** + * This is a data provider + * param string The UTF8 message + * param string The WebSocket framed message, then base64_encoded + */ + public static function UnframeMessageProvider() { + return array( + array('Hello World!', 'gYydAIfa1WXrtvIg0LXvbOP7'), + array('!@#$%^&*()-=_+[]{}\|/.,<>`~', 'gZv+h96r38f9j9vZ+IHWrvOWoayF9oX6gtfRqfKXwOeg'), + array('ಠ_ಠ', 'gYfnSpu5B/g75gf4Ow=='), + array( + "The quick brown fox jumps over the lazy dog. All work and no play makes Chris a dull boy. I'm trying to get past 128 characters for a unit test here...", + 'gf4Amahb14P8M7Kj2S6+4MN7tfHHLLmjzjSvo8IuuvPbe7j1zSn398A+9+/JIa6jzDSwrYh7lu/Ee6Ds2jD34sY/9+3He6fvySL37skwsvCIGL/xwSj34og/ou/Ee7Xs0XX3o+F8uqPcKa7qxjz398d7sObce6fi2y/3sppj9+DAOqXiyy+y8dt7sezae7aj3TW+94gvsvDce7/m2j75rYY=' + ) + ); + } + + public static function underflowProvider() { + return array( + array('isFinal', ''), + array('getRsv1', ''), + array('getRsv2', ''), + array('getRsv3', ''), + array('getOpcode', ''), + array('isMasked', '10000001'), + array('getPayloadLength', '10000001'), + array('getPayloadLength', '1000000111111110'), + array('getMaskingKey', '1000000110000111'), + array('getPayload', '100000011000000100011100101010101001100111110100') + ); + } + + /** + * @dataProvider underflowProvider + * + * @covers Ratchet\RFC6455\Messaging\Frame::isFinal + * @covers Ratchet\RFC6455\Messaging\Frame::getRsv1 + * @covers Ratchet\RFC6455\Messaging\Frame::getRsv2 + * @covers Ratchet\RFC6455\Messaging\Frame::getRsv3 + * @covers Ratchet\RFC6455\Messaging\Frame::getOpcode + * @covers Ratchet\RFC6455\Messaging\Frame::isMasked + * @covers Ratchet\RFC6455\Messaging\Frame::getPayloadLength + * @covers Ratchet\RFC6455\Messaging\Frame::getMaskingKey + * @covers Ratchet\RFC6455\Messaging\Frame::getPayload + */ + public function testUnderflowExceptionFromAllTheMethodsMimickingBuffering($method, $bin) { + $this->setExpectedException('\UnderflowException'); + if (!empty($bin)) { + $this->_frame->addBuffer(static::encode($bin)); + } + call_user_func(array($this->_frame, $method)); + } + + /** + * A data provider for testing the first byte of a WebSocket frame + * param bool Given, is the byte indicate this is the final frame + * param int Given, what is the expected opcode + * param string of 0|1 Each character represents a bit in the byte + */ + public static function firstByteProvider() { + return array( + array(false, false, false, true, 8, '00011000'), + array(true, false, true, false, 10, '10101010'), + array(false, false, false, false, 15, '00001111'), + array(true, false, false, false, 1, '10000001'), + array(true, true, true, true, 15, '11111111'), + array(true, true, false, false, 7, '11000111') + ); + } + + /** + * @dataProvider firstByteProvider + * covers Ratchet\RFC6455\Messaging\Frame::isFinal + */ + public function testFinCodeFromBits($fin, $rsv1, $rsv2, $rsv3, $opcode, $bin) { + $this->_frame->addBuffer(static::encode($bin)); + $this->assertEquals($fin, $this->_frame->isFinal()); + } + + /** + * @dataProvider firstByteProvider + * covers Ratchet\RFC6455\Messaging\Frame::getRsv1 + * covers Ratchet\RFC6455\Messaging\Frame::getRsv2 + * covers Ratchet\RFC6455\Messaging\Frame::getRsv3 + */ + public function testGetRsvFromBits($fin, $rsv1, $rsv2, $rsv3, $opcode, $bin) { + $this->_frame->addBuffer(static::encode($bin)); + $this->assertEquals($rsv1, $this->_frame->getRsv1()); + $this->assertEquals($rsv2, $this->_frame->getRsv2()); + $this->assertEquals($rsv3, $this->_frame->getRsv3()); + } + + /** + * @dataProvider firstByteProvider + * covers Ratchet\RFC6455\Messaging\Frame::getOpcode + */ + public function testOpcodeFromBits($fin, $rsv1, $rsv2, $rsv3, $opcode, $bin) { + $this->_frame->addBuffer(static::encode($bin)); + $this->assertEquals($opcode, $this->_frame->getOpcode()); + } + + /** + * @dataProvider UnframeMessageProvider + * covers Ratchet\RFC6455\Messaging\Frame::isFinal + */ + public function testFinCodeFromFullMessage($msg, $encoded) { + $this->_frame->addBuffer(base64_decode($encoded)); + $this->assertTrue($this->_frame->isFinal()); + } + + /** + * @dataProvider UnframeMessageProvider + * covers Ratchet\RFC6455\Messaging\Frame::getOpcode + */ + public function testOpcodeFromFullMessage($msg, $encoded) { + $this->_frame->addBuffer(base64_decode($encoded)); + $this->assertEquals(1, $this->_frame->getOpcode()); + } + + public static function payloadLengthDescriptionProvider() { + return array( + array(7, '01110101'), + array(7, '01111101'), + array(23, '01111110'), + array(71, '01111111'), + array(7, '00000000'), // Should this throw an exception? Can a payload be empty? + array(7, '00000001') + ); + } + + /** + * @dataProvider payloadLengthDescriptionProvider + * covers Ratchet\RFC6455\Messaging\Frame::addBuffer + * covers Ratchet\RFC6455\Messaging\Frame::getFirstPayloadVal + */ + public function testFirstPayloadDesignationValue($bits, $bin) { + $this->_frame->addBuffer(static::encode($this->_firstByteFinText)); + $this->_frame->addBuffer(static::encode($bin)); + $ref = new \ReflectionClass($this->_frame); + $cb = $ref->getMethod('getFirstPayloadVal'); + $cb->setAccessible(true); + $this->assertEquals(bindec($bin), $cb->invoke($this->_frame)); + } + + /** + * covers Ratchet\RFC6455\Messaging\Frame::getFirstPayloadVal + */ + public function testFirstPayloadValUnderflow() { + $ref = new \ReflectionClass($this->_frame); + $cb = $ref->getMethod('getFirstPayloadVal'); + $cb->setAccessible(true); + $this->setExpectedException('UnderflowException'); + $cb->invoke($this->_frame); + } + + /** + * @dataProvider payloadLengthDescriptionProvider + * covers Ratchet\RFC6455\Messaging\Frame::getNumPayloadBits + */ + public function testDetermineHowManyBitsAreUsedToDescribePayload($expected_bits, $bin) { + $this->_frame->addBuffer(static::encode($this->_firstByteFinText)); + $this->_frame->addBuffer(static::encode($bin)); + $ref = new \ReflectionClass($this->_frame); + $cb = $ref->getMethod('getNumPayloadBits'); + $cb->setAccessible(true); + $this->assertEquals($expected_bits, $cb->invoke($this->_frame)); + } + + /** + * covers Ratchet\RFC6455\Messaging\Frame::getNumPayloadBits + */ + public function testgetNumPayloadBitsUnderflow() { + $ref = new \ReflectionClass($this->_frame); + $cb = $ref->getMethod('getNumPayloadBits'); + $cb->setAccessible(true); + $this->setExpectedException('UnderflowException'); + $cb->invoke($this->_frame); + } + + public function secondByteProvider() { + return array( + array(true, 1, '10000001'), + array(false, 1, '00000001'), + array(true, 125, $this->_secondByteMaskedSPL) + ); + } + /** + * @dataProvider secondByteProvider + * covers Ratchet\RFC6455\Messaging\Frame::isMasked + */ + public function testIsMaskedReturnsExpectedValue($masked, $payload_length, $bin) { + $this->_frame->addBuffer(static::encode($this->_firstByteFinText)); + $this->_frame->addBuffer(static::encode($bin)); + $this->assertEquals($masked, $this->_frame->isMasked()); + } + + /** + * @dataProvider UnframeMessageProvider + * covers Ratchet\RFC6455\Messaging\Frame::isMasked + */ + public function testIsMaskedFromFullMessage($msg, $encoded) { + $this->_frame->addBuffer(base64_decode($encoded)); + $this->assertTrue($this->_frame->isMasked()); + } + + /** + * @dataProvider secondByteProvider + * covers Ratchet\RFC6455\Messaging\Frame::getPayloadLength + */ + public function testGetPayloadLengthWhenOnlyFirstFrameIsUsed($masked, $payload_length, $bin) { + $this->_frame->addBuffer(static::encode($this->_firstByteFinText)); + $this->_frame->addBuffer(static::encode($bin)); + $this->assertEquals($payload_length, $this->_frame->getPayloadLength()); + } + + /** + * @dataProvider UnframeMessageProvider + * covers Ratchet\RFC6455\Messaging\Frame::getPayloadLength + * @todo Not yet testing when second additional payload length descriptor + */ + public function testGetPayloadLengthFromFullMessage($msg, $encoded) { + $this->_frame->addBuffer(base64_decode($encoded)); + $this->assertEquals(strlen($msg), $this->_frame->getPayloadLength()); + } + + public function maskingKeyProvider() { + $frame = new Frame; + return array( + array($frame->generateMaskingKey()), + array($frame->generateMaskingKey()), + array($frame->generateMaskingKey()) + ); + } + + /** + * @dataProvider maskingKeyProvider + * covers Ratchet\RFC6455\Messaging\Frame::getMaskingKey + * @todo I I wrote the dataProvider incorrectly, skipping for now + */ + public function testGetMaskingKey($mask) { + $this->_frame->addBuffer(static::encode($this->_firstByteFinText)); + $this->_frame->addBuffer(static::encode($this->_secondByteMaskedSPL)); + $this->_frame->addBuffer($mask); + $this->assertEquals($mask, $this->_frame->getMaskingKey()); + } + + /** + * covers Ratchet\RFC6455\Messaging\Frame::getMaskingKey + */ + public function testGetMaskingKeyOnUnmaskedPayload() { + $frame = new Frame('Hello World!'); + $this->assertEquals('', $frame->getMaskingKey()); + } + + /** + * @dataProvider UnframeMessageProvider + * covers Ratchet\RFC6455\Messaging\Frame::getPayload + * @todo Move this test to bottom as it requires all methods of the class + */ + public function testUnframeFullMessage($unframed, $base_framed) { + $this->_frame->addBuffer(base64_decode($base_framed)); + $this->assertEquals($unframed, $this->_frame->getPayload()); + } + + public static function messageFragmentProvider() { + return array( + array(false, '', '', '', '', '') + ); + } + + /** + * @dataProvider UnframeMessageProvider + * covers Ratchet\RFC6455\Messaging\Frame::getPayload + */ + public function testCheckPiecingTogetherMessage($msg, $encoded) { + $framed = base64_decode($encoded); + for ($i = 0, $len = strlen($framed);$i < $len; $i++) { + $this->_frame->addBuffer(substr($framed, $i, 1)); + } + $this->assertEquals($msg, $this->_frame->getPayload()); + } + + /** + * covers Ratchet\RFC6455\Messaging\Frame::__construct + * covers Ratchet\RFC6455\Messaging\Frame::getPayloadLength + * covers Ratchet\RFC6455\Messaging\Frame::getPayload + */ + public function testLongCreate() { + $len = 65525; + $pl = $this->generateRandomString($len); + $frame = new Frame($pl, true, Frame::OP_PING); + $this->assertTrue($frame->isFinal()); + $this->assertEquals(Frame::OP_PING, $frame->getOpcode()); + $this->assertFalse($frame->isMasked()); + $this->assertEquals($len, $frame->getPayloadLength()); + $this->assertEquals($pl, $frame->getPayload()); + } + + /** + * covers Ratchet\RFC6455\Messaging\Frame::__construct + * covers Ratchet\RFC6455\Messaging\Frame::getPayloadLength + */ + public function testReallyLongCreate() { + $len = 65575; + $frame = new Frame($this->generateRandomString($len)); + $this->assertEquals($len, $frame->getPayloadLength()); + } + /** + * covers Ratchet\RFC6455\Messaging\Frame::__construct + * covers Ratchet\RFC6455\Messaging\Frame::extractOverflow + */ + public function testExtractOverflow() { + $string1 = $this->generateRandomString(); + $frame1 = new Frame($string1); + $string2 = $this->generateRandomString(); + $frame2 = new Frame($string2); + $cat = new Frame; + $cat->addBuffer($frame1->getContents() . $frame2->getContents()); + $this->assertEquals($frame1->getContents(), $cat->getContents()); + $this->assertEquals($string1, $cat->getPayload()); + $uncat = new Frame; + $uncat->addBuffer($cat->extractOverflow()); + $this->assertEquals($string1, $cat->getPayload()); + $this->assertEquals($string2, $uncat->getPayload()); + } + + /** + * covers Ratchet\RFC6455\Messaging\Frame::extractOverflow + */ + public function testEmptyExtractOverflow() { + $string = $this->generateRandomString(); + $frame = new Frame($string); + $this->assertEquals($string, $frame->getPayload()); + $this->assertEquals('', $frame->extractOverflow()); + $this->assertEquals($string, $frame->getPayload()); + } + + /** + * covers Ratchet\RFC6455\Messaging\Frame::getContents + */ + public function testGetContents() { + $msg = 'The quick brown fox jumps over the lazy dog.'; + $frame1 = new Frame($msg); + $frame2 = new Frame($msg); + $frame2->maskPayload(); + $this->assertNotEquals($frame1->getContents(), $frame2->getContents()); + $this->assertEquals(strlen($frame1->getContents()) + 4, strlen($frame2->getContents())); + } + + /** + * covers Ratchet\RFC6455\Messaging\Frame::maskPayload + */ + public function testMasking() { + $msg = 'The quick brown fox jumps over the lazy dog.'; + $frame = new Frame($msg); + $frame->maskPayload(); + $this->assertTrue($frame->isMasked()); + $this->assertEquals($msg, $frame->getPayload()); + } + + /** + * covers Ratchet\RFC6455\Messaging\Frame::unMaskPayload + */ + public function testUnMaskPayload() { + $string = $this->generateRandomString(); + $frame = new Frame($string); + $frame->maskPayload()->unMaskPayload(); + $this->assertFalse($frame->isMasked()); + $this->assertEquals($string, $frame->getPayload()); + } + + /** + * covers Ratchet\RFC6455\Messaging\Frame::generateMaskingKey + */ + public function testGenerateMaskingKey() { + $dupe = false; + $done = array(); + for ($i = 0; $i < 10; $i++) { + $new = $this->_frame->generateMaskingKey(); + if (in_array($new, $done)) { + $dupe = true; + } + $done[] = $new; + } + $this->assertEquals(4, strlen($new)); + $this->assertFalse($dupe); + } + + /** + * covers Ratchet\RFC6455\Messaging\Frame::maskPayload + */ + public function testGivenMaskIsValid() { + $this->setExpectedException('InvalidArgumentException'); + $this->_frame->maskPayload('hello world'); + } + + /** + * covers Ratchet\RFC6455\Messaging\Frame::maskPayload + */ + public function testGivenMaskIsValidAscii() { + if (!extension_loaded('mbstring')) { + $this->markTestSkipped("mbstring required for this test"); + return; + } + $this->setExpectedException('OutOfBoundsException'); + $this->_frame->maskPayload('x✖'); + } + + protected function generateRandomString($length = 10, $addSpaces = true, $addNumbers = true) { + $characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"$%&/()=[]{}'; // ยง + $useChars = array(); + for($i = 0; $i < $length; $i++) { + $useChars[] = $characters[mt_rand(0, strlen($characters) - 1)]; + } + if($addSpaces === true) { + array_push($useChars, ' ', ' ', ' ', ' ', ' ', ' '); + } + if($addNumbers === true) { + array_push($useChars, rand(0, 9), rand(0, 9), rand(0, 9)); + } + shuffle($useChars); + $randomString = trim(implode('', $useChars)); + $randomString = substr($randomString, 0, $length); + return $randomString; + } + + /** + * There was a frame boundary issue when the first 3 bytes of a frame with a payload greater than + * 126 was added to the frame buffer and then Frame::getPayloadLength was called. It would cause the frame + * to set the payload length to 126 and then not recalculate it once the full length information was available. + * + * This is fixed by setting the defPayLen back to -1 before the underflow exception is thrown. + * + * covers Ratchet\RFC6455\Messaging\Frame::getPayloadLength + * covers Ratchet\RFC6455\Messaging\Frame::extractOverflow + */ + public function testFrameDeliveredOneByteAtATime() { + $startHeader = "\x01\x7e\x01\x00"; // header for a text frame of 256 - non-final + $framePayload = str_repeat("*", 256); + $rawOverflow = "xyz"; + $rawFrame = $startHeader . $framePayload . $rawOverflow; + $frame = new Frame(); + $payloadLen = 256; + for ($i = 0; $i < strlen($rawFrame); $i++) { + $frame->addBuffer($rawFrame[$i]); + try { + // payloadLen will + $payloadLen = $frame->getPayloadLength(); + } catch (\UnderflowException $e) { + if ($i > 2) { // we should get an underflow on 0,1,2 + $this->fail("Underflow exception when the frame length should be available"); + } + } + if ($payloadLen !== 256) { + $this->fail("Payload length of " . $payloadLen . " should have been 256."); + } + } + // make sure the overflow is good + $this->assertEquals($rawOverflow, $frame->extractOverflow()); + } +} diff --git a/tests/unit/Messaging/MessageTest.php b/tests/unit/Messaging/MessageTest.php new file mode 100644 index 0000000..1f7eab5 --- /dev/null +++ b/tests/unit/Messaging/MessageTest.php @@ -0,0 +1,58 @@ +message = new Message; + } + + public function testNoFrames() { + $this->assertFalse($this->message->isCoalesced()); + } + + public function testNoFramesOpCode() { + $this->setExpectedException('UnderflowException'); + $this->message->getOpCode(); + } + + public function testFragmentationPayload() { + $a = 'Hello '; + $b = 'World!'; + $f1 = new Frame($a, false); + $f2 = new Frame($b, true, Frame::OP_CONTINUE); + $this->message->addFrame($f1)->addFrame($f2); + $this->assertEquals(strlen($a . $b), $this->message->getPayloadLength()); + $this->assertEquals($a . $b, $this->message->getPayload()); + } + + public function testUnbufferedFragment() { + $this->message->addFrame(new Frame('The quick brow', false)); + $this->setExpectedException('UnderflowException'); + $this->message->getPayload(); + } + + public function testGetOpCode() { + $this->message + ->addFrame(new Frame('The quick brow', false, Frame::OP_TEXT)) + ->addFrame(new Frame('n fox jumps ov', false, Frame::OP_CONTINUE)) + ->addFrame(new Frame('er the lazy dog', true, Frame::OP_CONTINUE)) + ; + $this->assertEquals(Frame::OP_TEXT, $this->message->getOpCode()); + } + + public function testGetUnBufferedPayloadLength() { + $this->message + ->addFrame(new Frame('The quick brow', false, Frame::OP_TEXT)) + ->addFrame(new Frame('n fox jumps ov', false, Frame::OP_CONTINUE)) + ; + $this->assertEquals(28, $this->message->getPayloadLength()); + } +} \ No newline at end of file