/ tests / MailStsPolicyTest.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\MailSiteFactory;
use seekquarry\yioop\library\UnitTest;

/**
 * Tests for the MTA-STS policy helpers on MailSiteFactory:
 * mtaStsMode (posture to policy mode), mtaStsPolicy (policy file
 * body for one domain), and mtaStsDomainForHost (request host to
 * policy domain). All three are static pure functions of their
 * arguments, so the cases are self-contained; no setUp data or
 * teardown is required.
 *
 * @author Chris Pollett
 */
class MailStsPolicyTest extends UnitTest
{
    /**
     * No set-up state needed; the helpers are static pure
     * functions of their arguments.
     */
    public function setUp()
    {
    }
    /**
     * No teardown needed either.
     */
    public function tearDown()
    {
    }
    /**
     * Each delivery posture maps to the expected policy mode: the
     * insecure posture publishes nothing, spam publishes a testing
     * policy, and require publishes an enforce policy.
     */
    public function modeMappingTestCase()
    {
        $this->assertEqual('', MailSiteFactory::mtaStsMode(
            'insecure'), "insecure posture publishes no policy");
        $this->assertEqual('testing', MailSiteFactory::mtaStsMode(
            'spam'), "spam posture publishes a testing policy");
        $this->assertEqual('enforce', MailSiteFactory::mtaStsMode(
            'require'), "require posture publishes enforce");
    }
    /**
     * The policy body carries the RFC 8461 fields: the STSv1
     * version, the mode, both a wildcard and an apex mx line for
     * the domain, and a max_age, with CRLF line endings.
     */
    public function policyBodyTestCase()
    {
        $policy = MailSiteFactory::mtaStsPolicy('testing',
            'yioop.com');
        $this->assertTrue(strpos($policy, "version: STSv1") === 0,
            "policy starts with the STSv1 version line");
        $this->assertTrue(strpos($policy, "mode: testing") !== false,
            "policy carries the mode");
        $this->assertTrue(strpos($policy, "mx: *.yioop.com") !== false,
            "policy authorizes subdomain MX hosts");
        $this->assertTrue(strpos($policy, "mx: yioop.com") !== false,
            "policy authorizes an apex MX host");
        $this->assertTrue(strpos($policy, "max_age: ") !== false,
            "policy carries a max_age");
        $this->assertTrue(strpos($policy, "\r\n") !== false,
            "policy uses CRLF line endings");
    }
    /**
     * An empty mode yields an empty policy body, so the route can
     * treat it as nothing to serve.
     */
    public function emptyModeYieldsNoPolicyTestCase()
    {
        $this->assertEqual('', MailSiteFactory::mtaStsPolicy('',
            'yioop.com'), "no mode means no policy body");
    }
    /**
     * The dedicated mta-sts.<domain> host resolves to that
     * configured domain, matched case-insensitively, while a host
     * for an unconfigured domain resolves to nothing.
     */
    public function hostResolvesConfiguredDomainTestCase()
    {
        $domains = ['yioop.com', 'example.org'];
        $this->assertEqual('yioop.com',
            MailSiteFactory::mtaStsDomainForHost(
            'mta-sts.yioop.com', $domains),
            "mta-sts host resolves to its domain");
        $this->assertEqual('example.org',
            MailSiteFactory::mtaStsDomainForHost(
            'MTA-STS.Example.Org', $domains),
            "host match is case-insensitive");
        $this->assertEqual('',
            MailSiteFactory::mtaStsDomainForHost(
            'mta-sts.notmine.com', $domains),
            "unconfigured domain resolves to nothing");
    }
    /**
     * A bare configured domain (without the mta-sts prefix) does
     * not resolve, so the apex site is not mistaken for the policy
     * host.
     */
    public function bareDomainDoesNotResolveTestCase()
    {
        $domains = ['yioop.com'];
        $this->assertEqual('',
            MailSiteFactory::mtaStsDomainForHost('yioop.com',
            $domains), "bare domain is not the policy host");
    }
    /**
     * Loopback hosts resolve to the first configured domain as a
     * testing convenience, and a port suffix on the host is
     * ignored.
     */
    public function loopbackTestPathTestCase()
    {
        $domains = ['yioop.com', 'example.org'];
        $this->assertEqual('yioop.com',
            MailSiteFactory::mtaStsDomainForHost('localhost',
            $domains), "localhost resolves to the first domain");
        $this->assertEqual('yioop.com',
            MailSiteFactory::mtaStsDomainForHost('127.0.0.1:8080',
            $domains), "a port suffix is ignored");
    }
    /**
     * The suggested SPF and DMARC record strings carry their
     * version tags, SPF authorizes the domain's MX hosts with a
     * soft-fail default, and DMARC starts in report-only mode with
     * the report address woven into the rua tag when given.
     */
    public function spfAndDmarcRecordsTestCase()
    {
        $spf = MailSiteFactory::spfTxtRecord();
        $this->assertTrue(strpos($spf, 'v=spf1') === 0,
            "SPF record starts with the version tag");
        $this->assertTrue(strpos($spf, 'mx') !== false,
            "SPF authorizes the MX hosts");
        $this->assertTrue(strpos($spf, '~all') !== false,
            "SPF soft-fails other senders by default");
        $dmarc = MailSiteFactory::dmarcTxtRecord(
            'postmaster@yioop.com');
        $this->assertTrue(strpos($dmarc, 'v=DMARC1') === 0,
            "DMARC record starts with the version tag");
        $this->assertTrue(strpos($dmarc, 'p=none') !== false,
            "DMARC starts in report-only policy");
        $this->assertTrue(
            strpos($dmarc, 'rua=mailto:postmaster@yioop.com')
            !== false, "DMARC carries the aggregate-report address");
        $bare = MailSiteFactory::dmarcTxtRecord('');
        $this->assertTrue(strpos($bare, 'rua=') === false,
            "DMARC omits rua when no report address is given");
    }
    /**
     * The _mta-sts discovery record carries the STSv1 version and
     * the given id when a policy is published, and is empty when
     * the posture publishes no policy.
     */
    public function mtaStsTxtRecordTestCase()
    {
        $record = MailSiteFactory::mtaStsTxtRecord('testing', 42);
        $this->assertTrue(strpos($record, 'v=STSv1') === 0,
            "discovery record starts with the version tag");
        $this->assertTrue(strpos($record, 'id=42') !== false,
            "discovery record carries the policy id");
        $this->assertEqual('',
            MailSiteFactory::mtaStsTxtRecord('', 42),
            "no discovery record when no policy is published");
    }
    /**
     * The assembled suggestions include SPF and DMARC for every
     * domain, add the MTA-STS records only under a policy-
     * publishing posture, and skip them under the insecure
     * posture.
     */
    public function suggestedRecordsAssemblyTestCase()
    {
        $secure = MailSiteFactory::suggestedDnsRecords(
            ['yioop.com'], 'spam', 100, 'postmaster@yioop.com');
        $hosts = array_column($secure['yioop.com'], 'host');
        $this->assertTrue(in_array('yioop.com', $hosts),
            "SPF record present at the apex host");
        $this->assertTrue(in_array('_dmarc.yioop.com', $hosts),
            "DMARC record present at _dmarc host");
        $this->assertTrue(in_array('_mta-sts.yioop.com', $hosts),
            "MTA-STS discovery record present under spam posture");
        $this->assertTrue(in_array('mta-sts.yioop.com', $hosts),
            "MTA-STS policy host record present under spam");
        $insecure = MailSiteFactory::suggestedDnsRecords(
            ['yioop.com'], 'insecure', 100, '');
        $hosts = array_column($insecure['yioop.com'], 'host');
        $this->assertTrue(!in_array('_mta-sts.yioop.com', $hosts),
            "no MTA-STS record under the insecure posture");
        $this->assertTrue(in_array('yioop.com', $hosts),
            "SPF still suggested under the insecure posture");
    }
    /**
     * When a DKIM selector and public-key record are supplied, the
     * suggestions include the <selector>._domainkey.<domain> TXT
     * record carrying that value; when no record is supplied (no
     * key generated) the DKIM row is omitted.
     */
    public function dkimRecordInSuggestionsTestCase()
    {
        $with = MailSiteFactory::suggestedDnsRecords(['yioop.com'],
            'spam', 100, '', 'yioop20260602',
            'v=DKIM1; k=rsa; p=ABCDEF');
        $dkim = null;
        foreach ($with['yioop.com'] as $row) {
            if ($row['host'] === 'yioop20260602._domainkey.yioop.com') {
                $dkim = $row;
            }
        }
        $this->assertTrue($dkim !== null,
            "DKIM record present at the selector host");
        $this->assertEqual('v=DKIM1; k=rsa; p=ABCDEF',
            $dkim['value'], "DKIM record carries the key value");
        $without = MailSiteFactory::suggestedDnsRecords(
            ['yioop.com'], 'spam', 100, '', 'yioop20260602', '');
        $hosts = array_column($without['yioop.com'], 'host');
        $this->assertTrue(
            !in_array('yioop20260602._domainkey.yioop.com', $hosts),
            "no DKIM row when no key record is supplied");
    }
    /**
     * A www. or mta-sts. host of a domain already in the list is
     * not given its own record set, since it is not an independent
     * mail domain; a bare www./mta-sts. host whose apex is not
     * listed still gets one.
     */
    public function subdomainOfListedApexSkippedTestCase()
    {
        $records = MailSiteFactory::suggestedDnsRecords(
            ['pollett.org', 'mta-sts.pollett.org',
            'www.pollett.org'], 'spam', 100, '');
        $this->assertTrue(isset($records['pollett.org']),
            "apex mail domain keeps its record set");
        $this->assertTrue(!isset($records['mta-sts.pollett.org']),
            "mta-sts host of a listed apex is skipped");
        $this->assertTrue(!isset($records['www.pollett.org']),
            "www host of a listed apex is skipped");
        $alone = MailSiteFactory::suggestedDnsRecords(
            ['www.example.com'], 'spam', 100, '');
        $this->assertTrue(isset($alone['www.example.com']),
            "www host with no listed apex still gets a set");
    }
}
X