121 lines
4.3 KiB
PHP
121 lines
4.3 KiB
PHP
<?php
|
|
|
|
namespace Tests\Support;
|
|
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* A throwaway SMTP server for exactly one connection, run as a real
|
|
* subprocess.
|
|
*
|
|
* MailboxTester opens a real socket through Symfony's EsmtpTransport — the
|
|
* same class production traffic uses — so proving its SUCCESS path (not
|
|
* only the refused-connection failure every other test in this file uses)
|
|
* means something has to answer on the other end of a real TCP connection.
|
|
* PHP is single-threaded: a "server" living in the same process as the test
|
|
* could never accept() a connection while that same process is blocked
|
|
* inside the socket call it is supposed to be answering. Hence a genuine
|
|
* child process (proc_open) rather than a Fake* stub swapped through the
|
|
* container, which is how every other fake in this suite (SSH, Proxmox,
|
|
* WireGuard — see tests/Pest.php) works.
|
|
*/
|
|
final class FakeSmtpServer
|
|
{
|
|
/** @var resource */
|
|
private $process;
|
|
|
|
/** @var array<int, resource> */
|
|
private array $pipes;
|
|
|
|
public readonly int $port;
|
|
|
|
private function __construct($process, array $pipes, int $port)
|
|
{
|
|
$this->process = $process;
|
|
$this->pipes = $pipes;
|
|
$this->port = $port;
|
|
}
|
|
|
|
/** Accepts one connection and completes a full, successful send. */
|
|
public static function succeeding(): self
|
|
{
|
|
return self::spawn(['failAt' => null]);
|
|
}
|
|
|
|
/**
|
|
* Accepts one connection, advertises AUTH LOGIN in its EHLO response,
|
|
* and completes a full successful send — UNLESS the client attempts to
|
|
* authenticate, which is rejected outright.
|
|
*
|
|
* The point is proving a client deliberately did NOT attempt AUTH, not
|
|
* merely that it could succeed without it: succeeding() never advertises
|
|
* AUTH at all, so Symfony's EsmtpTransport::handleAuth() is never even
|
|
* reachable against it — a mailbox that wrongly called setUsername()
|
|
* with a real value would pass every test that uses succeeding() too.
|
|
* Advertising the capability here is what makes the two cases tell
|
|
* apart: "chose not to authenticate" against a server that offered it,
|
|
* versus "had no opportunity to" against one that never did.
|
|
*/
|
|
public static function succeedingUnlessAuthAttempted(): self
|
|
{
|
|
return self::spawn(['failAt' => null, 'advertiseAuth' => true]);
|
|
}
|
|
|
|
/**
|
|
* Accepts one connection and rejects it with a real SMTP response at the
|
|
* given stage. 'rcpt' is the simplest choice for a caller that just wants
|
|
* *a* rejection: MAIL FROM has already succeeded by then, so the failure
|
|
* is unambiguously the server's own — 'ehlo' is deliberately unsupported
|
|
* because EsmtpTransport retries a failed EHLO as plain HELO rather than
|
|
* giving up, which this one-shot server does not simulate.
|
|
*/
|
|
public static function rejectingAt(string $stage, string $response): self
|
|
{
|
|
return self::spawn(['failAt' => $stage, 'failResponse' => $response]);
|
|
}
|
|
|
|
private static function spawn(array $config): self
|
|
{
|
|
$process = proc_open(
|
|
[PHP_BINARY, __DIR__.'/fake_smtp_server_process.php'],
|
|
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
|
|
$pipes,
|
|
);
|
|
|
|
if (! is_resource($process)) {
|
|
throw new RuntimeException('Could not start the fake SMTP server subprocess.');
|
|
}
|
|
|
|
fwrite($pipes[0], json_encode($config));
|
|
fclose($pipes[0]);
|
|
|
|
// A bound port comes back almost instantly; this timeout only
|
|
// matters if the subprocess never starts at all, so the test suite
|
|
// fails fast instead of hanging.
|
|
stream_set_timeout($pipes[1], 5);
|
|
$port = (int) trim((string) fgets($pipes[1]));
|
|
|
|
if ($port <= 0) {
|
|
$stderr = stream_get_contents($pipes[2]);
|
|
proc_terminate($process);
|
|
proc_close($process);
|
|
|
|
throw new RuntimeException("Fake SMTP server failed to bind a port. stderr: {$stderr}");
|
|
}
|
|
|
|
return new self($process, $pipes, $port);
|
|
}
|
|
|
|
public function stop(): void
|
|
{
|
|
foreach ($this->pipes as $pipe) {
|
|
if (is_resource($pipe)) {
|
|
fclose($pipe);
|
|
}
|
|
}
|
|
|
|
proc_terminate($this->process);
|
|
proc_close($this->process);
|
|
}
|
|
}
|