Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

E2E HTTP client tests #1626

Merged
merged 1 commit into from
Nov 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions tests/HttpClient/HttpClientTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

declare(strict_types=1);

namespace Sentry\Tests\HttpClient;

use PHPUnit\Framework\TestCase;
use Sentry\HttpClient\HttpClient;
use Sentry\HttpClient\Request;
use Sentry\Options;
use Sentry\Util\Http;

class HttpClientTest extends TestCase
{
use TestServer;

public function testClientMakesRequestWithCorrectHeadersMethodAndPath(): void
{
$testServer = $this->startTestServer();

$options = new Options([
'dsn' => "http://publicKey@{$testServer}/200",
]);

$request = new Request();
$request->setStringBody('test');

$client = new HttpClient($sdkIdentifier = 'sentry.php', $sdkVersion = 'testing');
$response = $client->sendRequest($request, $options);

$serverOutput = $this->stopTestServer();

$this->assertTrue($response->isSuccess());
$this->assertEquals(200, $response->getStatusCode());

// This assertion is here to test that the response headers are correctly parsed
$this->assertEquals(200, (int) $response->getHeaderLine('x-sentry-test-server-status-code'));

$this->assertTrue($serverOutput['compressed']);
$this->assertEquals($response->getStatusCode(), $serverOutput['status']);
$this->assertEquals($request->getStringBody(), $serverOutput['body']);
$this->assertEquals('/api/200/envelope/', $serverOutput['server']['REQUEST_URI']);
$this->assertEquals("{$sdkIdentifier}/{$sdkVersion}", $serverOutput['headers']['User-Agent']);

$expectedHeaders = Http::getRequestHeaders($options->getDsn(), $sdkIdentifier, $sdkVersion);
foreach ($expectedHeaders as $expectedHeader) {
[$headerName, $headerValue] = explode(': ', $expectedHeader);
$this->assertEquals($headerValue, $serverOutput['headers'][$headerName]);
}
}

public function testClientMakesUncompressedRequestWhenCompressionDisabled(): void
{
$testServer = $this->startTestServer();

$options = new Options([
'dsn' => "http://publicKey@{$testServer}/200",
'http_compression' => false,
]);

$request = new Request();
$request->setStringBody('test');

$client = new HttpClient('sentry.php', 'testing');
$response = $client->sendRequest($request, $options);

$serverOutput = $this->stopTestServer();

$this->assertTrue($response->isSuccess());
$this->assertFalse($serverOutput['compressed']);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals($response->getStatusCode(), $serverOutput['status']);
$this->assertEquals($request->getStringBody(), $serverOutput['body']);
$this->assertEquals(\strlen($request->getStringBody()), $serverOutput['headers']['Content-Length']);
}

public function testThrowsExceptionIfDsnOptionIsNotSet(): void
{
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('The DSN option must be set to use the HttpClient.');

$options = new Options(['dsn' => null]);

$client = new HttpClient('sentry.php', 'testing');
$client->sendRequest(new Request(), $options);
}

public function testThrowsExceptionIfRequestDataIsEmpty(): void
{
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('The request data is empty.');

$options = new Options(['dsn' => 'https://publicKey@example.com/1']);

$client = new HttpClient('sentry.php', 'testing');
$client->sendRequest(new Request(), $options);
}
}
145 changes: 145 additions & 0 deletions tests/HttpClient/TestServer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<?php

declare(strict_types=1);

namespace Sentry\Tests\HttpClient;

/**
* This is a test server that can be used to test the HttpClient.
*
* It spawns the PHP development server, captures the output and returns it to the caller.
*
* In your test call `$this->startTestServer()` to start the server and get the address.
* After you have made your request, call `$this->stopTestServer()` to stop the server and get the output.
*
* Thanks to Stripe for the inspiration: https://github.com/stripe/stripe-php/blob/e0a960c8655b21b21c3ba2e5927f432eeda9105f/tests/TestServer.php
*/
trait TestServer
{
/**
* @var string the path to the output file
*/
protected static $serverOutputFile = __DIR__ . '/../testserver/output.json';

/**
* @var resource|null the server process handle
*/
protected $serverProcess;

/**
* @var resource|null the server stderr handle
*/
protected $serverStderr;

/**
* @var int the port on which the server is listening, this default value was randomly chosen
*/
protected $serverPort = 44884;

public function startTestServer(): string
{
if ($this->serverProcess !== null) {
throw new \RuntimeException('There is already a test server instance running.');
}

if (file_exists(self::$serverOutputFile)) {
unlink(self::$serverOutputFile);
}

$pipes = [];

$this->serverProcess = proc_open(
$command = sprintf(
'php -S localhost:%d -t %s',
$this->serverPort,
realpath(__DIR__ . '/../testserver')
),
[2 => ['pipe', 'w']],
$pipes
);

$this->serverStderr = $pipes[2];

$pid = proc_get_status($this->serverProcess)['pid'];

if (!\is_resource($this->serverProcess)) {
throw new \RuntimeException("Error starting test server on pid {$pid}, command failed: {$command}");
}

while (true) {
$conn = @fsockopen('localhost', $this->serverPort);
if (\is_resource($conn)) {
fclose($conn);

break;
}
}

return "localhost:{$this->serverPort}";
}

/**
* Stop the test server and return the output from the server.
*
* @return array{
* body: string,
* status: int,
* server: array<string, string>,
* headers: array<string, string>,
* compressed: bool,
* }
*/
public function stopTestServer(): array
{
if (!$this->serverProcess) {
throw new \RuntimeException('There is no test server instance running.');
}

for ($i = 0; $i < 20; ++$i) {
$status = proc_get_status($this->serverProcess);

if (!$status['running']) {
break;
}

$this->killServerProcess($status['pid']);

usleep(10000);
}

if ($status['running']) {
throw new \RuntimeException('Could not kill test server');
}

if (!file_exists(self::$serverOutputFile)) {
stream_set_blocking($this->serverStderr, false);
$stderrOutput = stream_get_contents($this->serverStderr);

echo $stderrOutput . \PHP_EOL;

throw new \RuntimeException('Test server did not write output file');
}

proc_close($this->serverProcess);

$this->serverProcess = null;
$this->serverStderr = null;

return json_decode(file_get_contents(self::$serverOutputFile), true);
}

private function killServerProcess(int $pid): void
{
if (\PHP_OS_FAMILY === 'Windows') {
exec("taskkill /pid {$pid} /f /t");
} else {
// Kills any child processes -- the php test server appears to start up a child.
exec("pkill -P {$pid}");

// Kill the parent process.
exec("kill {$pid}");
}

proc_terminate($this->serverProcess, 9);
}
}
1 change: 1 addition & 0 deletions tests/testserver/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
output.json
48 changes: 48 additions & 0 deletions tests/testserver/index.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

$outputFile = __DIR__ . '/output.json';

// We expect the path to be `/api/<project_id>/envelope/`.
// We use the project ID to determine the status code so we need to extract it from the path
$path = trim(parse_url($_SERVER['REQUEST_URI'], \PHP_URL_PATH), '/');

if (!preg_match('/api\/\d+\/envelope/', $path)) {
http_response_code(204);

return;
}

$status = (int) explode('/', $path)[1];

$headers = getallheaders();

$rawBody = file_get_contents('php://input');

$compressed = false;

if (!isset($headers['Content-Encoding'])) {
$body = $rawBody;
} elseif ($headers['Content-Encoding'] === 'gzip') {
$body = gzdecode($rawBody);
$compressed = true;
} else {
$body = '__unable to decode body__';
}

$output = [
'body' => $body,
'status' => $status,
'server' => $_SERVER,
'headers' => $headers,
'compressed' => $compressed,
];

file_put_contents(__DIR__ . '/output.json', json_encode($output, \JSON_PRETTY_PRINT));

header('X-Sentry-Test-Server-Status-Code: ' . $status);

http_response_code($status);

return 'Processed.';