<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\tests;
use seekquarry\yioop\library\mail\ImapClient;
use seekquarry\yioop\library\UnitTest;
/**
* Test-only subclass of ImapClient that re-exposes the two protected
* static error classifiers as public methods so they can be unit
* tested directly. classifyConnectError() and certReasonDetail() are
* pure functions of their string argument; exercising them does not
* require a socket, so the real connect path (integration territory)
* is left alone. Nothing here changes ImapClient's own API surface.
*
* @author Chris Pollett
*/
class TestableImapClient extends ImapClient
{
/**
* Public passthrough to the protected classifyConnectError().
*
* @param string $raw raw PHP-level stream error text
* @return string one of the ImapClient ERROR_* tokens, cert and
* connect-failed tokens suffixed with ": <detail>"
*/
public static function classify(string $raw): string
{
return self::classifyConnectError($raw);
}
/**
* Public passthrough to the protected certReasonDetail().
*
* @param string $raw raw PHP-level TLS warning text
* @return string the concise human-meaningful reason
*/
public static function certReason(string $raw): string
{
return self::certReasonDetail($raw);
}
}
/**
* Unit tests for ImapClient against a scripted server pipe. Each test
* opens a stream_socket_pair, hands one end to ImapClient via the
* injected-socket constructor argument, writes the server's scripted
* responses onto the other end, and asserts the client parses them
* the way RFC 3501 implies.
*
* Real network round-trips are not exercised here; the connect and
* STARTTLS / IMAPS handshake paths are integration territory that
* requires a real server. Everything that lives entirely in the
* parser or in the static error classifiers, however, is covered.
*
* @author Chris Pollett
*/
class ImapClientTest extends UnitTest
{
/**
* Server side of a stream_socket_pair held across test setup so the
* setUp method can write scripted responses onto it before
* each test invokes ImapClient.
* @var resource
*/
public $server_side;
/**
* Client side of the same pair, passed to ImapClient as its
* "injected socket" so the class does not try a real network
* connection.
* @var resource
*/
public $client_side;
/**
* Creates the socket pair the per-test setup will drive.
*/
public function setUp()
{
$pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM,
STREAM_IPPROTO_IP);
$this->server_side = $pair[0];
$this->client_side = $pair[1];
}
/**
* Closes both ends of the test pair if the test did not.
*/
public function tearDown()
{
if (is_resource($this->server_side)) {
fclose($this->server_side);
}
if (is_resource($this->client_side)) {
fclose($this->client_side);
}
}
/**
* Convenience: queue a string onto the server end of the pair so
* the client's next socket read sees it.
*
* @param string $bytes raw bytes (callers responsible for any
* required CRLF framing)
*/
public function pushServer($bytes)
{
fwrite($this->server_side, $bytes);
}
/**
* A bare "* OK ready" greeting plus a single tagged OK response
* to one LOGIN command should round-trip cleanly: status "OK",
* empty untagged list besides the greeting itself, no literals.
*/
public function loginOkTestCase()
{
$this->pushServer("* OK IMAP ready\r\n");
$this->pushServer("X1 OK LOGIN completed\r\n");
$client = new ImapClient("test.invalid", 143, 5, $this->client_side);
$response = $client->send('LOGIN "alice" "secret"');
$this->assertEqual("OK", $response['status'],
"LOGIN OK comes back as status OK");
$this->assertEqual("LOGIN completed", $response['detail'],
"Detail line is what follows the status token");
$this->assertEqual(0, count($response['untagged']),
"No untagged lines between greeting drain and tagged OK");
$this->assertEqual(0, count($response['literals']),
"No literal payloads on a plain LOGIN");
}
/**
* A LOGIN that the server refuses should surface as status "NO"
* with the server's detail string carried through verbatim.
*/
public function loginNoTestCase()
{
$this->pushServer("* OK IMAP ready\r\n");
$this->pushServer("X1 NO LOGIN failed: bad credentials\r\n");
$client = new ImapClient("test.invalid", 143, 5, $this->client_side);
$response = $client->send('LOGIN "alice" "wrong"');
$this->assertEqual("NO", $response['status'],
"Refused LOGIN comes back as status NO");
$this->assertEqual("LOGIN failed: bad credentials",
$response['detail'],
"Server's reason text is preserved");
}
/**
* SELECT INBOX with the typical untagged EXISTS / FLAGS / RECENT
* stanza should return those as $response['untagged'] in order,
* with no literals.
*/
public function selectInboxUntaggedTestCase()
{
$this->pushServer("* OK IMAP ready\r\n");
$this->pushServer("* 42 EXISTS\r\n");
$this->pushServer("* 3 RECENT\r\n");
$this->pushServer("* FLAGS (\\Answered \\Flagged \\Deleted " .
"\\Seen \\Draft)\r\n");
$this->pushServer("* OK [UIDVALIDITY 1234567890] UIDs valid\r\n");
$this->pushServer("X1 OK [READ-WRITE] SELECT completed\r\n");
$client = new ImapClient("test.invalid", 143, 5, $this->client_side);
$response = $client->send('SELECT "INBOX"');
$this->assertEqual("OK", $response['status'],
"SELECT completes with status OK");
$this->assertEqual(4, count($response['untagged']),
"EXISTS, RECENT, FLAGS, UIDVALIDITY each captured");
$this->assertTrue(
str_contains($response['untagged'][0], "EXISTS"),
"First untagged line is EXISTS");
$this->assertTrue(
str_contains($response['untagged'][2], "FLAGS"),
"Third untagged line is FLAGS");
}
/**
* A FETCH that returns a literal payload (the {N} form) must
* read exactly N bytes and place them in $response['literals']
* keyed by the untagged-line index where they appeared.
*/
public function fetchWithLiteralPayloadTestCase()
{
$body = "From: alice@example.com\r\n" .
"Subject: hi\r\n\r\n" .
"Body text.\r\n";
$size = strlen($body);
$this->pushServer("* OK IMAP ready\r\n");
$this->pushServer("* 1 FETCH (RFC822 {{$size}}\r\n");
$this->pushServer($body);
$this->pushServer(")\r\n");
$this->pushServer("X1 OK FETCH completed\r\n");
$client = new ImapClient("test.invalid", 143, 5, $this->client_side);
$response = $client->send('FETCH 1 RFC822');
$this->assertEqual("OK", $response['status'],
"FETCH completes OK");
$this->assertEqual(1, count($response['literals']),
"Exactly one literal payload captured");
$this->assertEqual($body, $response['literals'][0],
"Literal payload is byte-identical to what server sent");
}
/**
* A tagged response with no detail (just "X1 OK" + CRLF) should
* still parse: status is "OK", detail is the empty string,
* untagged and literals are empty.
*/
public function bareTaggedOkTestCase()
{
$this->pushServer("* OK IMAP ready\r\n");
$this->pushServer("X1 OK\r\n");
$client = new ImapClient("test.invalid", 143, 5, $this->client_side);
$response = $client->send('NOOP');
$this->assertEqual("OK", $response['status'],
"Bare OK is still status OK");
$this->assertEqual("", $response['detail'],
"No detail when nothing follows the status token");
}
/**
* The IMAP greeting on connect must begin with "* OK"; any other
* shape is a fatal session error and the constructor must throw.
*/
public function rejectsBadGreetingTestCase()
{
$this->pushServer("* BYE banned\r\n");
try {
new ImapClient("test.invalid", 143, 5, $this->client_side);
$this->assertTrue(false,
"Bad greeting should have thrown but didn't");
} catch (\Exception $e) {
$this->assertTrue(
str_contains($e->getMessage(),
ImapClient::ERROR_BAD_GREETING),
"Exception carries the bad-greeting token");
}
}
/**
* Tags must increment per call so the server can disambiguate
* pipelined commands. Three sequential sends should issue tags
* X1, X2, X3.
*/
public function tagsIncrementTestCase()
{
$this->pushServer("* OK ready\r\n");
$this->pushServer("X1 OK first\r\n");
$this->pushServer("X2 OK second\r\n");
$this->pushServer("X3 OK third\r\n");
$client = new ImapClient("test.invalid", 143, 5, $this->client_side);
$a = $client->send("NOOP");
$b = $client->send("NOOP");
$c = $client->send("NOOP");
$this->assertEqual("first", $a['detail'],
"First send matches X1");
$this->assertEqual("second", $b['detail'],
"Second send matches X2");
$this->assertEqual("third", $c['detail'],
"Third send matches X3");
}
/**
* classifyConnectError() must separate the three certificate
* failure modes. A name mismatch is its own token even though
* PHP's error text for it also contains "certificate verify
* failed" — the hostname check runs first and wins, so the user
* is pointed at the Host field rather than the self-signed
* option. An expired certificate gets its own token too, and a
* generic verification failure with no more specific marker
* falls through to ERROR_CERT_VERIFY. All three carry a
* ": <detail>" suffix.
*/
public function classifyCertErrorsTestCase()
{
$hostname_raw = "stream_socket_enable_crypto(): SSL operation " .
"failed with code 1. OpenSSL Error messages: " .
"error:0A000086:SSL routines::certificate verify failed. " .
"Peer certificate CN=`mail.example.net` did not match " .
"expected CN=`pollett.org`";
$hostname = TestableImapClient::classify($hostname_raw);
$this->assertTrue(
str_starts_with($hostname, ImapClient::ERROR_CERT_HOSTNAME),
"Name-mismatch text classifies as the cert-hostname token");
$this->assertTrue(
!str_starts_with($hostname, ImapClient::ERROR_CERT_VERIFY),
"Hostname check wins over the cert-verify substring");
$this->assertTrue(str_contains($hostname, ":"),
"Cert-hostname token carries a ': detail' suffix");
$expired_raw = "stream_socket_enable_crypto(): SSL operation " .
"failed with code 1. OpenSSL Error messages: " .
"error:0A000086:SSL routines::certificate has expired";
$expired = TestableImapClient::classify($expired_raw);
$this->assertTrue(
str_starts_with($expired, ImapClient::ERROR_CERT_EXPIRED),
"Expired-certificate text classifies as the cert-expired token");
$verify_raw = "stream_socket_enable_crypto(): SSL operation " .
"failed with code 1. OpenSSL Error messages: " .
"error:0A000086:SSL routines::certificate verify failed";
$verify = TestableImapClient::classify($verify_raw);
$this->assertTrue(
str_starts_with($verify, ImapClient::ERROR_CERT_VERIFY),
"Generic verify failure falls through to the cert-verify token");
$refused = TestableImapClient::classify("Connection refused");
$this->assertEqual(ImapClient::ERROR_CONNECTION_REFUSED, $refused,
"A non-cert error still classifies to its plain token");
}
/**
* certReasonDetail() pulls the actionable part out of PHP's
* boilerplate-wrapped TLS warnings: a "Peer certificate ... did
* not match expected ..." notice is lifted verbatim, and
* otherwise the text after the last "SSL routines:" marker is
* used. A string with no known marker comes back trimmed but
* otherwise whole.
*/
public function certReasonDetailTestCase()
{
$mismatch = "SSL operation failed with code 1. OpenSSL Error " .
"messages: error:0A000086:SSL routines::certificate verify " .
"failed. Peer certificate CN=`mail.example.net` did not " .
"match expected CN=`pollett.org`";
$lifted = TestableImapClient::certReason($mismatch);
$this->assertTrue(str_starts_with($lifted, "Peer certificate"),
"The peer-certificate clause is lifted out verbatim");
$this->assertTrue(
!str_contains($lifted, "SSL operation failed"),
"The OpenSSL boilerplate prefix is dropped");
$selfsigned = "SSL operation failed with code 1. OpenSSL Error " .
"messages: error:0A000086:SSL routines::self signed certificate";
$reason = TestableImapClient::certReason($selfsigned);
$this->assertEqual("self signed certificate", $reason,
"Text after the SSL-routines marker is what is returned");
$bare = TestableImapClient::certReason(" odd unwrapped text ");
$this->assertEqual("odd unwrapped text", $bare,
"A string with no known marker comes back trimmed but whole");
}
/**
* When a command's response has not arrived yet and the client is
* running inside a fiber, the read gives the event loop back with
* Fiber::suspend() rather than blocking; once the server answers and
* the fiber is resumed, the read completes and the response parses
* normally. This is what keeps one slow mail server from freezing the
* single-process web server.
*/
public function cooperativeReadSuspendsTestCase()
{
$this->pushServer("* OK IMAP ready\r\n");
$client = new ImapClient("test.invalid", 143, 5,
$this->client_side);
$fiber = new \Fiber(function () use ($client) {
return $client->send('NOOP');
});
$waited = $fiber->start();
$this->assertTrue($fiber->isSuspended(),
"the read suspends the fiber when no response is ready yet");
$this->assertTrue(is_array($waited) && isset($waited['read']),
"the fiber suspends asking to wait for the socket to read");
$this->pushServer("X1 OK NOOP done\r\n");
while ($fiber->isSuspended()) {
$fiber->resume();
}
$response = $fiber->getReturn();
$this->assertEqual("OK", $response['status'],
"once the response arrives the suspended read completes");
}
}