CluPilotCloud/tests/Support/FakeSmtpServer.php

102 lines
3.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 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);
}
}