/ tests / MailServerConfigTest.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;

/*
 * Signal MailServer.php to define its class without running any of
 * its executable top-level code, so loading it for this test never
 * boots the daemon or disturbs a running web server. Defined
 * before the MailServer class is referenced below.
 */
if (!defined(
    "seekquarry\\yioop\\executables\\MAIL_SERVER_TEST_LOAD")) {
    define("seekquarry\\yioop\\executables\\MAIL_SERVER_TEST_LOAD",
        true);
}

use seekquarry\yioop\executables\MailServer;
use seekquarry\yioop\library\UnitTest;

/**
 * Tests for MailServer::deriveListenConfig, the pure function that
 * turns the mail settings into the listener configuration array
 * MailSite::listen consumes. The cases walk the decision matrix:
 * TLS available or not, STARTTLS versus implicit TLS, each delivery
 * posture, and loopback versus external binding. Because the
 * function takes its inputs as arguments and returns an array, the
 * cases are self-contained; no setUp data or teardown is required.
 *
 * @author Chris Pollett
 */
class MailServerConfigTest extends UnitTest
{
    /**
     * The conventional port settings used by most cases: plaintext
     * SMTP 25 and IMAP 143, secure SMTPS 465 and IMAPS 993.
     * @var array
     */
    public $ports;
    /**
     * A SERVER_CONTEXT carrying an ssl block, standing in for a
     * configured certificate so TLS is available.
     * @var array
     */
    public $ssl_context;

    /**
     * Sets up the shared port map and a TLS-bearing server
     * context reused across cases.
     */
    public function setUp()
    {
        $this->ports = ['smtp' => 25, 'submission' => 587,
            'imap' => 143, 'smtps' => 465, 'imaps' => 993];
        $this->ssl_context = ['ssl' =>
            ['local_cert' => '/x.crt', 'local_pk' => '/x.key']];
    }
    /**
     * No teardown needed; the function under test is pure.
     */
    public function tearDown()
    {
    }
    /**
     * Calls deriveListenConfig with this test's standard inputs,
     * allowing each argument to be overridden.
     * @param array $server_context SERVER_CONTEXT to pass
     * @param string $posture delivery-security posture
     * @param bool $use_starttls STARTTLS versus implicit TLS
     * @param bool $external public versus loopback bind
     * @param array $ports optional port-map override
     * @return array the derived listen configuration
     */
    public function derive($server_context, $posture, $use_starttls,
        $external, $ports = null)
    {
        $ports = ($ports === null) ? $this->ports : $ports;
        return MailServer::deriveListenConfig($ports,
            $server_context, $posture, $use_starttls, $external,
            'mail.example.com');
    }

    /**
     * With no certificate the secure ports stay closed, STARTTLS
     * cannot be offered, and SERVER_CONTEXT is not forwarded to
     * listen.
     */
    public function noCertificateClosesSecurePortsTestCase()
    {
        $config = $this->derive([], 'insecure', false, false);
        $this->assertEqual(25, $config['SMTP_PORT'],
            "plaintext SMTP binds on 25 without a cert");
        $this->assertEqual(143, $config['IMAP_PORT'],
            "plaintext IMAP binds on 143 without a cert");
        $this->assertEqual(587, $config['SUBMISSION_PORT'],
            "submission port binds without a cert");
        $this->assertEqual(0, $config['SMTPS_PORT'],
            "no implicit SMTPS socket without a cert");
        $this->assertEqual(0, $config['IMAPS_PORT'],
            "no implicit IMAPS socket without a cert");
        $this->assertTrue(!isset($config['SERVER_CONTEXT']),
            "SERVER_CONTEXT not forwarded when no cert");
    }
    /**
     * The insecure posture allows plaintext AUTH even when a
     * certificate is present.
     */
    public function insecurePostureAllowsPlaintextAuthTestCase()
    {
        $config = $this->derive($this->ssl_context, 'insecure',
            false, true);
        $this->assertTrue($config['ALLOW_PLAINTEXT_AUTH'],
            "insecure posture allows plaintext AUTH");
    }
    /**
     * With a certificate, STARTTLS selected, and a secure posture,
     * every listener opens together: the plaintext SMTP, submission,
     * and IMAP ports (which upgrade with STARTTLS) plus the wrapped
     * implicit-TLS SMTPS and IMAPS ports. Plaintext AUTH is refused
     * and the context is forwarded. This replaces the earlier
     * either/or in which STARTTLS moved the listeners onto the
     * secure port numbers and left no implicit-TLS socket.
     */
    public function starttlsOpensAllPortsTestCase()
    {
        $config = $this->derive($this->ssl_context, 'spam', true,
            false);
        $this->assertEqual(25, $config['SMTP_PORT'],
            "STARTTLS: plaintext SMTP stays on 25");
        $this->assertEqual(587, $config['SUBMISSION_PORT'],
            "STARTTLS: submission listener opens on 587");
        $this->assertEqual(143, $config['IMAP_PORT'],
            "STARTTLS: plaintext IMAP stays on 143");
        $this->assertEqual(465, $config['SMTPS_PORT'],
            "STARTTLS: wrapped SMTPS still opens on 465");
        $this->assertEqual(993, $config['IMAPS_PORT'],
            "STARTTLS: wrapped IMAPS still opens on 993");
        $this->assertTrue(!$config['ALLOW_PLAINTEXT_AUTH'],
            "spam posture refuses plaintext AUTH");
        $this->assertTrue(isset($config['SERVER_CONTEXT']),
            "SERVER_CONTEXT forwarded when a cert is present");
    }
    /**
     * With a certificate and implicit TLS, the plaintext listeners
     * keep the plaintext-port settings (for inbound mail and
     * opportunistic STARTTLS) and the secure ports open additional
     * implicit-TLS sockets.
     */
    public function implicitTlsOpensWrappedSecurePortsTestCase()
    {
        $config = $this->derive($this->ssl_context, 'require',
            false, true);
        $this->assertEqual(25, $config['SMTP_PORT'],
            "implicit: plaintext SMTP stays on 25");
        $this->assertEqual(143, $config['IMAP_PORT'],
            "implicit: plaintext IMAP stays on 143");
        $this->assertEqual(465, $config['SMTPS_PORT'],
            "implicit: wrapped SMTPS opens on 465");
        $this->assertEqual(993, $config['IMAPS_PORT'],
            "implicit: wrapped IMAPS opens on 993");
        $this->assertTrue(!$config['ALLOW_PLAINTEXT_AUTH'],
            "require posture refuses plaintext AUTH");
    }
    /**
     * A loopback bind with no certificate still allows plaintext
     * AUTH even under a secure posture, so a developer rig that
     * cannot offer TLS is not locked out of its own mailbox.
     */
    public function loopbackNoCertAllowsPlaintextAuthTestCase()
    {
        $config = $this->derive([], 'spam', false, false);
        $this->assertTrue($config['ALLOW_PLAINTEXT_AUTH'],
            "loopback without a cert keeps plaintext AUTH usable");
        $this->assertEqual('127.0.0.1', $config['BIND'],
            "loopback bind is 127.0.0.1");
    }
    /**
     * An external secure posture with no certificate refuses
     * plaintext AUTH: the bind is public, so the loopback
     * exception does not apply and credentials must not cross the
     * network in the clear.
     */
    public function externalNoCertRefusesPlaintextAuthTestCase()
    {
        $config = $this->derive([], 'require', false, true);
        $this->assertTrue(!$config['ALLOW_PLAINTEXT_AUTH'],
            "external secure posture refuses plaintext AUTH");
        $this->assertEqual('0.0.0.0', $config['BIND'],
            "external bind is 0.0.0.0");
    }
    /**
     * STARTTLS requested but no certificate present falls back to
     * the plaintext-port settings with no secure sockets, because
     * STARTTLS cannot be offered without a cert.
     */
    public function starttlsWithoutCertFallsBackTestCase()
    {
        $config = $this->derive([], 'insecure', true, false);
        $this->assertEqual(25, $config['SMTP_PORT'],
            "no cert: SMTP stays on the plaintext port");
        $this->assertEqual(143, $config['IMAP_PORT'],
            "no cert: IMAP stays on the plaintext port");
        $this->assertEqual(0, $config['SMTPS_PORT'],
            "no cert: no secure SMTP socket even with STARTTLS on");
        $this->assertEqual(0, $config['IMAPS_PORT'],
            "no cert: no secure IMAP socket even with STARTTLS on");
    }
}
X