diff --git a/Makefile b/Makefile index 30ded37cb..ceacdb242 100644 --- a/Makefile +++ b/Makefile @@ -53,4 +53,7 @@ demo: layers.json: php runtime/layers/layer-list.php -.PHONY: runtimes website website-preview website-assets demo layers.json +test-stack: + serverless deploy -c tests/serverless.tests.yml + +.PHONY: runtimes website website-preview website-assets demo layers.json test-stack diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 5c2002910..3de6368c7 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -12,6 +12,7 @@ parameters: - %rootDir%/../../../tests/Bridge/Symfony/logs/* - %rootDir%/../../../tests/Sam/Php/* - %rootDir%/../../../tests/Sam/PhpFpm/* + - %rootDir%/../../../tests/Functional/fpm/* ignoreErrors: - diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9070f9bc1..deb5c82a4 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -5,9 +5,11 @@ ./tests/ + ./tests/Functional ./tests/Sam + ./tests/Functional ./tests/Sam diff --git a/runtime/layers/fpm/php.ini b/runtime/layers/fpm/php.ini index ce677ea17..a3856b6c0 100644 --- a/runtime/layers/fpm/php.ini +++ b/runtime/layers/fpm/php.ini @@ -34,3 +34,8 @@ variables_order="EGPCS" ; The lambda environment is not compatible with fastcgi_finish_request ; See https://github.com/brefphp/bref/issues/214 disable_functions=fastcgi_finish_request + +; The total upload size limit is 6Mb, we override the defaults to match this limit +; API Gateway has a 10Mb limit, but Lambda's is 6Mb +post_max_size=6M +upload_max_filesize=6M diff --git a/runtime/layers/web/default.conf b/runtime/layers/web/default.conf index d4bc737f5..b1a9bad95 100644 --- a/runtime/layers/web/default.conf +++ b/runtime/layers/web/default.conf @@ -2,6 +2,8 @@ server { server_name php-docker.local; root /var/task/##DOCUMENT_ROOT##; + client_max_body_size 6M; + location / { # try to serve file directly, fallback to handler try_files $uri /##HANDLER_DR##$is_args$args; diff --git a/tests/Functional/FpmRuntimeTest.php b/tests/Functional/FpmRuntimeTest.php new file mode 100644 index 000000000..0776cb9ef --- /dev/null +++ b/tests/Functional/FpmRuntimeTest.php @@ -0,0 +1,244 @@ +http = new Client([ + 'base_uri' => 'https://5octfcz6gc.execute-api.eu-west-1.amazonaws.com/dev/', + 'http_errors' => false, + ]); + } + + public function test GET() + { + $response = $this->http->request('GET'); + + $this->assertResponseSuccessful($response); + self::assertEquals('Hello world!', $this->getBody($response)); + } + + public function test GET with query parameter() + { + $response = $this->http->request('GET', '?name=Abby'); + + $this->assertResponseSuccessful($response); + self::assertEquals('Hello Abby', $this->getBody($response)); + } + + public function test stderr do not show in the HTTP response() + { + $response = $this->http->request('GET', '?stderr=1'); + + $this->assertResponseSuccessful($response); + self::assertNotContains('This is a test log into stderr', $this->responseAsString($response)); + } + + public function test error_log function() + { + $response = $this->http->request('GET', '?error_log=1'); + + $this->assertResponseSuccessful($response); + self::assertNotContains('This is a test log from error_log', $this->responseAsString($response)); + } + + public function test uncaught exception returns a 500 without the details() + { + $response = $this->http->request('GET', '?exception=1'); + + self::assertSame(500, $response->getStatusCode()); + self::assertNotContains('This is an uncaught exception', $this->responseAsString($response)); + } + + public function test error returns a 500 without the details() + { + $response = $this->http->request('GET', '?error=1'); + + self::assertSame(500, $response->getStatusCode()); + self::assertNotContains('strlen() expects exactly 1 parameter, 0 given', $this->responseAsString($response)); + } + + public function test fatal error returns a 500 without the details() + { + $response = $this->http->request('GET', '?fatal_error=1'); + + self::assertSame(500, $response->getStatusCode()); + self::assertNotContains("require(): Failed opening required 'foo'", $this->responseAsString($response)); + } + + public function test warnings do not fail the request and do not appear in the response() + { + $response = $this->http->request('GET', '?warning=1'); + + $this->assertResponseSuccessful($response); + self::assertEquals('Hello world!', $this->getBody($response)); + self::assertNotContains('This is a test warning', $this->responseAsString($response)); + } + + public function test php extensions() + { + $response = $this->http->request('GET', '?extensions=1'); + $extensions = $this->getJsonBody($response); + sort($extensions); + + self::assertEquals([ + 'Core', + 'PDO', + 'Phar', + 'Reflection', + 'SPL', + 'SimpleXML', + 'Zend OPcache', + 'bcmath', + 'cgi-fcgi', + 'ctype', + 'curl', + 'date', + 'dom', + 'exif', + 'fileinfo', + 'filter', + 'ftp', + 'gd', + 'gettext', + 'hash', + 'iconv', + 'json', + 'libxml', + 'mbstring', + 'mysqli', + 'mysqlnd', + 'openssl', + 'pcntl', + 'pcre', + 'pdo_sqlite', + 'posix', + 'readline', + 'session', + 'soap', + 'sockets', + 'sodium', + 'sqlite3', + 'standard', + 'tokenizer', + 'xml', + 'xmlreader', + 'xmlwriter', + 'xsl', + 'zip', + 'zlib', + ], $extensions); + } + + /** + * Check some PHP config values + */ + public function test php config() + { + $response = $this->http->request('GET', '?php-config=1'); + + self::assertArraySubset([ + // On PHP-FPM we don't want errors to be sent to stdout because that sends them to the HTTP response + 'display_errors' => '0', + // This is sent to PHP-FPM, which sends them back to CloudWatch + 'error_log' => null, + // This is the default production value + 'error_reporting' => (string) (E_ALL & ~E_DEPRECATED & ~E_STRICT), + 'extension_dir' => '/opt/bref/lib/php/extensions/no-debug-zts-20190902', + // Same limit as API Gateway + 'max_execution_time' => '30', + 'max_input_time' => '60', + // Use the max amount of memory possibly available, lambda will limit us + 'memory_limit' => '3008M', + 'opcache.enable' => '1', + 'opcache.enable_cli' => '0', + // Since we have PHP-FPM we don't need the file cache here + 'opcache.file_cache' => null, + 'opcache.max_accelerated_files' => '10000', + 'opcache.memory_consumption' => '128', + // This is to make sure that we don't strip comments from source code since it would break annotations + 'opcache.save_comments' => '1', + // The code is readonly on lambdas so it never changes + 'opcache.validate_timestamps' => '0', + 'short_open_tag' => '', + 'zend.assertions' => '-1', + 'zend.enable_gc' => '1', + // Check POST configuration + 'post_max_size' => '6M', + 'upload_max_filesize' => '6M', + ], $this->getJsonBody($response), false); + } + + public function test environment variables() + { + $response = $this->http->request('GET', '?env=1'); + + self::assertEquals([ + '$_ENV' => 'bar', + '$_SERVER' => 'bar', + 'getenv' => 'bar', + ], $this->getJsonBody($response)); + } + + public function test error on invalid URL() + { + $response = $this->http->request('GET', 'missing-handler'); + + self::assertSame(403, $response->getStatusCode()); + self::assertEquals(['message' => 'Missing Authentication Token'], $this->getJsonBody($response)); + } + + /** + * The API Gateway limit is 10Mb, but Lambda is 6Mb. + * + * @see https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html + * We check with 4Mb because this works. 5Mb fails, maybe because the whole size of the event + * is larger (because of the whole JSON formatting plus headers?). + */ + public function test max upload size is 6Mb() + { + $body4Mb = str_repeat(' ', 1024 * 1024 * 4); + $response = $this->http->request('POST', '', [ + 'body' => $body4Mb, + ]); + $this->assertResponseSuccessful($response); + self::assertEquals('Received 4Mb', $this->getBody($response)); + } + + private function assertResponseSuccessful(ResponseInterface $response): void + { + self::assertSame(200, $response->getStatusCode(), $this->getBody($response)); + } + + private function getBody(ResponseInterface $response): string + { + return $response->getBody()->__toString(); + } + + private function getJsonBody(ResponseInterface $response) + { + return json_decode($response->getBody()->getContents(), true); + } + + private function responseAsString(ResponseInterface $response): string + { + $string = ''; + foreach ($response->getHeaders() as $name => $values) { + $string .= $name . ': ' . implode(', ', $values) . "\n"; + } + $string .= "\n" . $this->getBody($response) . "\n"; + + return $string; + } +} diff --git a/tests/Functional/fpm/index.php b/tests/Functional/fpm/index.php new file mode 100644 index 000000000..bd3b0a5cd --- /dev/null +++ b/tests/Functional/fpm/index.php @@ -0,0 +1,58 @@ + $_ENV['FOO'] ?? null, + '$_SERVER' => $_SERVER['FOO'] ?? null, + 'getenv' => getenv('FOO'), + ], JSON_PRETTY_PRINT); + return; +} + +if ($_GET['stderr'] ?? false) { + $stderr = fopen('php://stderr', 'a'); + fwrite($stderr, 'This is a test log into stderr'); + fclose($stderr); +} + +if ($_GET['error_log'] ?? false) { + error_log('This is a test log from error_log'); +} + +if ($_GET['exception'] ?? false) { + throw new Exception('This is an uncaught exception'); +} + +if ($_GET['error'] ?? false) { + strlen(); +} + +if ($_GET['fatal_error'] ?? false) { + require 'foo'; +} + +if ($_GET['warning'] ?? false) { + trigger_error('This is a test warning', E_USER_WARNING); +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $body = file_get_contents('php://input'); + $bodySize = round(strlen($body) / 1024 / 1024); + echo "Received {$bodySize}Mb"; + return; +} + +echo 'Hello ' . ($_GET['name'] ?? 'world!'); diff --git a/tests/Sam/PhpFpmRuntimeTest.php b/tests/Sam/PhpFpmRuntimeTest.php index 52b30dadf..183bfcefe 100644 --- a/tests/Sam/PhpFpmRuntimeTest.php +++ b/tests/Sam/PhpFpmRuntimeTest.php @@ -7,6 +7,11 @@ use Psr\Http\Message\ResponseInterface; use Symfony\Component\Process\Process; +/** + * This test duplicates a little bit the FPM functional test. + * + * However, it is still useful to test logs (faster that downloading logs from live lambdas). + */ class PhpFpmRuntimeTest extends TestCase { /** @var string */ @@ -18,22 +23,6 @@ public function setUp() $this->logs = ''; } - public function test invocation without event() - { - $response = $this->invoke('/'); - - $this->assertResponseSuccessful($response); - self::assertEquals('Hello world!', $this->getBody($response), $this->logs); - } - - public function test invocation with event() - { - $response = $this->invoke('/?name=Abby'); - - $this->assertResponseSuccessful($response); - self::assertEquals('Hello Abby', $this->getBody($response), $this->logs); - } - public function test stderr ends up in logs() { $response = $this->invoke('/?stderr=1'); @@ -90,107 +79,6 @@ public function test warnings are logged() self::assertContains('Warning: This is a test warning in /var/task/tests/Sam', $this->logs); } - public function test php extensions() - { - $response = $this->invoke('/?extensions=1'); - $extensions = $this->getJsonBody($response); - sort($extensions); - - self::assertEquals([ - 'Core', - 'PDO', - 'Phar', - 'Reflection', - 'SPL', - 'SimpleXML', - 'Zend OPcache', - 'bcmath', - 'cgi-fcgi', - 'ctype', - 'curl', - 'date', - 'dom', - 'exif', - 'fileinfo', - 'filter', - 'ftp', - 'gd', - 'gettext', - 'hash', - 'iconv', - 'json', - 'libxml', - 'mbstring', - 'mysqli', - 'mysqlnd', - 'openssl', - 'pcntl', - 'pcre', - 'pdo_sqlite', - 'posix', - 'readline', - 'session', - 'soap', - 'sockets', - 'sodium', - 'sqlite3', - 'standard', - 'tokenizer', - 'xml', - 'xmlreader', - 'xmlwriter', - 'xsl', - 'zip', - 'zlib', - ], $extensions, $this->logs); - } - - /** - * Check some PHP config values - */ - public function test php config() - { - $response = $this->invoke('/?php-config=1'); - - self::assertArraySubset([ - // On PHP-FPM we don't want errors to be sent to stdout because that sends them to the HTTP response - 'display_errors' => '0', - // This is sent to PHP-FPM, which sends them back to CloudWatch - 'error_log' => null, - // This is the default production value - 'error_reporting' => (string) (E_ALL & ~E_DEPRECATED & ~E_STRICT), - 'extension_dir' => '/opt/bref/lib/php/extensions/no-debug-zts-20190902', - // Same limit as API Gateway - 'max_execution_time' => '30', - // Use the max amount of memory possibly available, lambda will limit us - 'memory_limit' => '3008M', - 'opcache.enable' => '1', - 'opcache.enable_cli' => '0', - // Since we have PHP-FPM we don't need the file cache here - 'opcache.file_cache' => null, - 'opcache.max_accelerated_files' => '10000', - 'opcache.memory_consumption' => '128', - // This is to make sure that we don't strip comments from source code since it would break annotations - 'opcache.save_comments' => '1', - // The code is readonly on lambdas so it never changes - 'opcache.validate_timestamps' => '0', - 'short_open_tag' => '', - 'zend.assertions' => '-1', - 'zend.enable_gc' => '1', - ], $this->getJsonBody($response), false, $this->logs); - } - - public function test environment variables() - { - $response = $this->invoke('/?env=1'); - - self::assertEquals([ - '$_ENV' => 'bar', - '$_SERVER' => 'bar', - 'getenv' => 'bar', - ], $this->getJsonBody($response), $this->logs); - } - /** * Check some PHP config values */ diff --git a/tests/serverless.tests.yml b/tests/serverless.tests.yml new file mode 100644 index 000000000..7ea5dd2b3 --- /dev/null +++ b/tests/serverless.tests.yml @@ -0,0 +1,35 @@ +service: bref-tests + +provider: + name: aws + runtime: provided + region: eu-west-1 + profile: bref-tests + apiGateway: + binaryMediaTypes: + - '*/*' + +plugins: + - ./index.js + +package: + exclude: + - '**' + include: + - 'src/**' + - 'tests/**' + - 'vendor/**' + +functions: + + fpm: + handler: tests/Functional/fpm/index.php + description: 'Bref FPM test' + timeout: 5 # in seconds (API Gateway has a timeout of 29 seconds) + reservedConcurrency: 2 + layers: + - ${bref:layer.php-74-fpm} + events: + - http: 'ANY /' + environment: + FOO: bar