/ tests / ImapClientTest.php
<?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");
    }
}
X