diff --git a/app/Http/Request.php b/app/Http/Request.php index 2b192d2b8d3f805c6e0f788b96cd5199f7f03dcf..fbaa48d637b52245e27f269753377c3025bf5db6 100644 --- a/app/Http/Request.php +++ b/app/Http/Request.php @@ -77,6 +77,18 @@ class Request extends ServerRequest return $currentProtocol; } + /** + * Was the request an ssl secured request + * + * You might want to return an error response when the request was not secured via ssl. + * + * @return bool + */ + public function isSslSecured(): bool + { + return $this->getProtocol() === 'https'; + } + /** * Check if the proxy is a trusted proxy. * @@ -120,9 +132,9 @@ class Request extends ServerRequest */ public function accepts(string $mimeType): bool { - $re = '/(^|,)' . preg_quote($mimeType, '/') . '(;|,|$)/i'; - return $this->hasHeader('Accept') && - preg_match($re, $this->getHeader('Accept')[0]); + list($type, $subType) = explode('/', $mimeType); + $re = '/(^|, ?)' . preg_quote($type, '/') . '\/(\*|' . preg_quote($subType, '/') . ')(;|,|$)/i'; + return $this->hasHeader('Accept') && preg_match($re, $this->getHeader('Accept')[0]); } public function get(string $key = null, $default = null) @@ -184,7 +196,9 @@ class Request extends ServerRequest */ public function getJson(bool $assoc = true, int $depth = 512, int $options = 0) { - if (strtolower($this->getHeader('Content-Type')[0]) !== 'application/json') { + if ($this->hasHeader('Content-Type') && + strtolower($this->getHeader('Content-Type')[0]) !== 'application/json' + ) { return null; } diff --git a/tests/TestCase.php b/tests/TestCase.php index 0872a016aedd5d19cf37e41c1bd3ec935e0b897f..085ad61b06c0bee8d3cfc940744c4e7d13a9b46e 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -8,6 +8,8 @@ use App\Environment; use Hugga\Console; use Mockery\Adapter\Phpunit\MockeryTestCase; use Mockery as m; +use Monolog\Handler\TestHandler; +use Monolog\Logger; use Whoops; abstract class TestCase extends MockeryTestCase @@ -62,6 +64,10 @@ abstract class TestCase extends MockeryTestCase $this->app->instance('whoops', $whoops); $whoops->unregister(); $whoops->shouldReceive('register')->andReturnSelf()->byDefault(); + + $logger = $this->mocks['logger'] = m::mock(Logger::class)->makePartial(); + $logger->__construct('app', [new TestHandler()]); + $this->app->instance('logger', $logger); /** @var Console|m\Mock $console */ $console = $this->mocks['console'] = m::mock(Console::class)->makePartial(); diff --git a/tests/Unit/ApplicationTest.php b/tests/Unit/ApplicationTest.php index cab77c8542be4a0db3279106be2834b800a2333c..d7b33816798f45de06dd30131954aebe15bc8b87 100644 --- a/tests/Unit/ApplicationTest.php +++ b/tests/Unit/ApplicationTest.php @@ -21,12 +21,9 @@ class ApplicationTest extends TestCase /** @test */ public function definesAnErrorHandlerForLogging() { - $handler = new PlainTextHandler($this->app->logger); - $handler->loggerOnly(true); - $this->app->initWhoops(); - self::assertEquals($handler, $this->app->get('whoops')->popHandler()); + self::assertInstanceOf(PlainTextHandler::class, $this->app->get('whoops')->popHandler()); } /** @test */ diff --git a/tests/Unit/Http/RequestTest.php b/tests/Unit/Http/RequestTest.php new file mode 100644 index 0000000000000000000000000000000000000000..fe0253c18baf42f11258ef07019f6ef6f4fe5879 --- /dev/null +++ b/tests/Unit/Http/RequestTest.php @@ -0,0 +1,382 @@ +<?php + +namespace Test\Unit\Http; + +use App\Http\Request; +use GuzzleHttp\Psr7\Utils; +use InvalidArgumentException; +use Mockery as m; +use Test\TestCase; +use function GuzzleHttp\Psr7\stream_for; + +class RequestTest extends TestCase +{ + /** @test */ + public function getQueryReturnsTheCompleteQuery() + { + $request = (new Request('GET', '/any/path')) + ->withQueryParams(['foo' => 42, 'bar' => 23]); + + $query = $request->getQuery(); + + self::assertSame(['foo' => 42, 'bar' => 23], $query); + } + + /** @test */ + public function getQueryReturnsSpecificParameter() + { + $request = (new Request('GET', '/any/path')) + ->withQueryParams(['foo' => 42, 'bar' => 23]); + + $query = $request->getQuery('foo'); + + self::assertSame(42, $query); + } + + /** @test */ + public function getQueryReturnsTheDefaultValue() + { + $request = new Request('GET', '/any/path'); + + $query = $request->getQuery('foo', 42); + + self::assertSame(42, $query); + } + + /** @test */ + public function getPostReturnsTheCompletePost() + { + $request = (new Request('POST', '/any/path')) + ->withParsedBody(['foo' => 42, 'bar' => 23]); + + $post = $request->getPost(); + + self::assertSame(['foo' => 42, 'bar' => 23], $post); + } + + /** @test */ + public function getPostReturnsSpecificParameter() + { + $request = (new Request('POST', '/any/path')) + ->withParsedBody(['foo' => 42, 'bar' => 23]); + + $post = $request->getPost('foo'); + + self::assertSame(42, $post); + } + + /** @test */ + public function getPostReturnsTheDefaultValue() + { + $request = new Request('POST', '/any/path'); + + $post = $request->getPost('foo', 42); + + self::assertSame(42, $post); + } + + /** @test */ + public function getJsonReturnsTheRequestBodyJsonDecoded() + { + $data = [ + 'foo' => 42, + 'bar' => 23, + 'baz' => null, + ]; + $request = (new Request('POST', '/any/path')) + ->withBody(Utils::streamFor(json_encode($data))); + + self::assertSame($data, $request->getJson()); + } + + /** @test */ + public function getJsonRetrunsNullWhenTheContentTypeHeaderIsNotJson() + { + $request = (new Request('POST', '/any/path', [ + 'Content-Type' => 'text/plain', + ]))->withBody(Utils::streamFor(json_encode(['a' => 'b']))); + + self::assertNull($request->getJson()); + } + + /** @test */ + public function getJsonLogsNoticeWhenBodyIsInvalid() + { + $request = (new Request('POST', '/any/path')) + ->withBody(Utils::streamFor("{foo:'bar'}")); // this is not json but javascript + + $this->mocks['logger']->shouldReceive('notice')->once(); + + $request->getJson(); + } + + /** @test */ + public function getJsonThrowsNotWhenTheJsonIsNull() + { + $request = (new Request('POST', '/any/path')) + ->withBody(Utils::streamFor(json_encode(null))); + + self::assertNull($request->getJson()); + } + + /** @test */ + public function getIpReturnsTheRemoteAddr() + { + $request = (new Request('GET', '/any/path', [], null, '1.1', [ + 'REMOTE_ADDR' => '172.19.0.9', + ])); + + $ip = $request->getIp(); + + self::assertSame('172.19.0.9', $ip); + } + + /** @dataProvider provideIpHeaders + * @test */ + public function getIpReturnsTheRealIpWhenProxyIsTrusted(array $header, string $remoteAddr, string $expected) + { + $config = $this->app->config; + $config->trustedProxies = [$remoteAddr]; + $request = (new Request('GET', '/any/path', $header, null, '1.1', [ + 'REMOTE_ADDR' => $remoteAddr, + ])); + + $ip = $request->getIp(); + + self::assertSame($expected, $ip); + } + + public function provideIpHeaders() + { + return [ + 'X-Real-Ip' => [['X-Real-Ip' => '8.8.8.8'], '10.0.0.1', '8.8.8.8'], + 'X-Forwarded-For' => [['X-Forwarded-For' => '8.8.8.8'], '10.0.0.1', '8.8.8.8'], + 'precedence' => [[ // prefers x-real-ip + 'X-Forwarded-For' => '23.0.4.2', + 'X-Real-Ip' => '8.8.8.8', + ], '10.0.0.1', '8.8.8.8'], + 'last-entry' => [[ // uses the last entry + 'X-Forwarded-For' => '23.0.4.2, 8.8.8.8', + ], '10.0.0.1', '8.8.8.8'], + ]; + } + + /** @test */ + public function getIpReturnsTheRemoteAddrIfNoRealIpHeaderGiven() + { + $config = $this->app->config; + $config->trustedProxies = ['10.0.0.1']; + $request = (new Request('GET', '/any/path', [], null, '1.1', [ + 'REMOTE_ADDR' => '10.0.0.1', + ])); + + $ip = $request->getIp(); + + self::assertSame('10.0.0.1', $ip); + } + + /** @test */ + public function getIpReturnsTheRemoteAddrWhenProxyIsUntrusted() + { + /** @var Request|m\MockInterface $request */ + $request = m::mock(Request::class)->makePartial(); + $request->__construct('GET', '/any/path', [ + 'X-Forwarded-For' => '23.0.4.2' + ], null, '1.1', [ + 'REMOTE_ADDR' => '8.8.8.8', + ]); + $request->shouldReceive('isTrustedForward')->once()->andReturnFalse(); + + $ip = $request->getIp(); + + self::assertSame('8.8.8.8', $ip); + } + + /** @test */ + public function getReturnsTheValueFromQuery() + { + $request = (new Request('GET', '/any/path')) + ->withQueryParams(['foo' => 'bar']); + + self::assertSame('bar', $request->get('foo')); + } + + /** @test */ + public function getReturnsTheValueFromPost() + { + $request = (new Request('GET', '/any/path')) + ->withQueryParams(['foo' => 'bar']) + ->withParsedBody(['foo' => 'baz']); + + self::assertSame('baz', $request->get('foo')); + } + + /** @test */ + public function getReturnsTheValueFromJson() + { + $request = (new Request('GET', '/any/path')) + ->withQueryParams(['foo' => 'bar']) + ->withBody(Utils::streamFor(json_encode(['foo' => 'baz']))); + + self::assertSame('baz', $request->get('foo')); + } + + /** @test */ + public function getReturnsTheMergedArray() + { + $request = (new Request('GET', '/any/path')) + ->withQueryParams(['foo' => 'bar']) + ->withBody(Utils::streamFor(json_encode(['answer' => 42]))); + + self::assertSame([ + 'foo' => 'bar', + 'answer' => 42, + ], $request->get()); + } + + /** @test */ + public function getProtocolReturnsTheProtocolTheClientUsed() + { + // by default (on cli) it is http + $request = new Request('GET', '/any/path'); + + $protocol = $request->getProtocol(); + + self::assertSame('http', $protocol); + } + + /** @test */ + public function getProtocolReadsServerParamHttps() + { + $request = new Request('GET', '/any/path', [], null, '1.1', ['HTTPS' => 'on']); + + $protocol = $request->getProtocol(); + + self::assertSame('https', $protocol); + } + + /** @test */ + public function getProtocolAcceptsXForwardedProtoForTrustedProxies() + { + /** @var m\MockInterface|Request $request */ + $request = m::mock(Request::class)->makePartial(); + $request->__construct('GET', '/any/path', ['X-Forwarded-Proto' => 'foobar']); + $request->shouldReceive('isTrustedForward')->once()->andReturn(true); + + $protocol = $request->getProtocol(); + + self::assertSame('foobar', $protocol); + } + + /** @test */ + public function getProtocolReturnsCurrentProtocolIfNoForwardedProtocolGiven() + { + /** @var m\MockInterface|Request $request */ + $request = m::mock(Request::class)->makePartial(); + $request->__construct('GET', '/any/path', []); + $request->shouldReceive('isTrustedForward')->once()->andReturn(true); + + $protocol = $request->getProtocol(); + + self::assertSame('http', $protocol); + } + + /** @test */ + public function getProtocolOffMeansNoSsl() + { + $request = new Request('GET', '/any/path', [], null, '1.1', ['HTTPS' => 'off']); + + $protocol = $request->getProtocol(); + + self::assertSame('http', $protocol); + } + + /** @test */ + public function isTrustedForwardIsFalseWhenNoProxiesAreTrusted() + { + $this->app->config->trustedProxies = []; + $request = new Request('GET', '/any/path'); + + $trusted = $request->isTrustedForward(); + + self::assertFalse($trusted); + } + + /** @test */ + public function isTrustedForwardIsTrueWhenTheRemoteAddrMatches() + { + $this->app->config->trustedProxies = ['127.0.0.1']; + $request = new Request('GET', '/any/path', [], null, '1.1', ['REMOTE_ADDR' => '127.0.0.1']); + + $trusted = $request->isTrustedForward(); + + self::assertTrue($trusted); + } + + /** @test */ + public function isTrustedForwardIsTrueWhenTheRemoteAddrMatchesAnyRange() + { + $this->app->config->trustedProxies = ['127.0.0.1', '10.23.42.0/24']; + $request = new Request('GET', '/any/path', [], null, '1.1', ['REMOTE_ADDR' => '10.23.42.1']); + + $trusted = $request->isTrustedForward(); + + self::assertTrue($trusted); + } + + /** @test */ + public function isTrustedForwardIsFalseWhenTheProxyIsUntrusted() + { + $this->app->config->trustedProxies = ['127.0.0.1', '10.23.42.0/24']; + $request = new Request('GET', '/any/path', [], null, '1.1', ['REMOTE_ADDR' => '192.168.0.42']); + + $trusted = $request->isTrustedForward(); + + self::assertFalse($trusted); + } + + /** @test */ + public function isSslSecuredIsTrueWhenTheProtocolIsHttps() + { + /** @var m\MockInterface|Request $request */ + $request = m::mock(Request::class)->makePartial(); + $request->__construct('GET', '/any/path'); + $request->shouldReceive('getProtocol')->once()->andReturn('https'); + + $secured = $request->isSslSecured(); + + self::assertTrue($secured); + } + + /** @test + * @dataProvider provideAcceptHeaders */ + public function acceptsReturnsIfTheMimeTypeIsAccepted($header, $type, $accepted) + { + $request = new Request('GET', '/any/pah', [ + 'accept' => $header, + ]); + + self::assertSame($accepted, $request->accepts($type)); + } + + public function provideAcceptHeaders() + { + return [ + ['text/html', 'text/html', true], + ['text/html', 'application/json', false], + ['text/html,application/json;q=0.8', 'application/json', true], + ['image/png,image/*;q=0.8', 'image/jpeg', true], + ['text/html, application/xhtml+xml', 'application/xhtml+xml', true], + ]; + } + + /** @test */ + public function acceptsIgnoresAsteriskSlashAsterisk() + { + $request = new Request('GET', '/any/pah', [ + 'accept' => 'text/html,*/*;q=0.4', + ]); + + self::assertFalse($request->accepts('application/json')); + } +}