/ tests / DmarcCheckTest.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\DkimKey;
use seekquarry\yioop\library\mail\DmarcCheck;
use seekquarry\yioop\library\mail\SpfCheck;
use seekquarry\yioop\library\UnitTest;

/**
 * Tests the network-free logic of the DMARC evaluator: organizational
 * domain extraction, relaxed and strict alignment, policy-record
 * parsing, and the choice between the main and subdomain policy. The
 * DNS-dependent record retrieval inside evaluate is exercised by hand
 * against live records and is not covered here, since unit tests must
 * not depend on the network.
 *
 * The cases reach the private helpers through reflection rather than
 * issuing DNS queries, so each runs quickly and deterministically.
 *
 * @author Chris Pollett
 */
class DmarcCheckTest extends UnitTest
{
    /**
     * No per-case state is needed; required because UnitTest
     * declares setUp abstract.
     */
    public function setUp()
    {
    }
    /**
     * No per-case teardown is needed; required because UnitTest
     * declares tearDown abstract.
     */
    public function tearDown()
    {
    }
    /**
     * Invokes a private static method of DmarcCheck by name.
     *
     * @param string $method method name to call
     * @param array $arguments positional arguments
     * @return mixed the method's return value
     */
    private function call($method, $arguments)
    {
        $reflected = new \ReflectionMethod(DmarcCheck::class,
            $method);
        return $reflected->invokeArgs(null, $arguments);
    }
    /**
     * The organizational domain is the registrable label plus its
     * public suffix: two labels for a plain TLD, and three for a
     * recognized multi-label suffix such as co.uk.
     */
    public function organizationalDomainTestCase()
    {
        $this->assertEqual('google.com',
            $this->call('organizationalDomain', ['a.b.google.com']),
            "a deep subdomain reduces to two labels");
        $this->assertEqual('example.com',
            $this->call('organizationalDomain', ['example.com']),
            "a two-label domain is unchanged");
        $this->assertEqual('example.co.uk',
            $this->call('organizationalDomain',
                ['mail.example.co.uk']),
            "a co.uk domain keeps three labels");
    }
    /**
     * Relaxed alignment matches on the organizational domain, strict
     * alignment requires an exact match, and an empty authenticated
     * domain never aligns.
     */
    public function alignmentTestCase()
    {
        $this->assertTrue(
            $this->call('aligned',
                ['mail.google.com', 'google.com', 'r']),
            "relaxed alignment matches the organizational domain");
        $this->assertFalse(
            $this->call('aligned',
                ['mail.google.com', 'google.com', 's']),
            "strict alignment rejects a subdomain");
        $this->assertTrue(
            $this->call('aligned',
                ['google.com', 'google.com', 's']),
            "strict alignment accepts an exact match");
        $this->assertFalse(
            $this->call('aligned', ['', 'google.com', 'r']),
            "an empty authenticated domain never aligns");
    }
    /**
     * A record parses into its tags with policy values lowercased,
     * and the subdomain policy is chosen over the main policy only
     * for a subdomain of the publishing domain.
     */
    public function policyParseAndSelectionTestCase()
    {
        $tags = $this->call('parsePolicy',
            ['v=DMARC1; p=Reject; sp=quarantine; adkim=s']);
        $this->assertEqual('reject', $tags['p'],
            "the main policy is parsed and lowercased");
        $this->assertEqual('s', $tags['adkim'],
            "the alignment mode is parsed");
        $this->assertEqual(DmarcCheck::POLICY_QUARANTINE,
            $this->call('policyForDomain',
                [$tags, 'mail.example.com']),
            "a subdomain uses the subdomain policy");
        $this->assertEqual(DmarcCheck::POLICY_REJECT,
            $this->call('policyForDomain', [$tags, 'example.com']),
            "the organizational domain uses the main policy");
    }
    /**
     * With no DMARC record fetched, evaluate reports none and takes
     * no action; an aligned DKIM pass reports a DMARC pass without
     * needing SPF. These use a domain that publishes no record so
     * the lookup is a quick miss.
     */
    public function evaluateWithoutRecordTestCase()
    {
        $outcome = DmarcCheck::evaluate('example.invalid',
            DkimKey::VERIFY_NONE, '', SpfCheck::NONE, '');
        $this->assertEqual(DmarcCheck::NONE, $outcome['result'],
            "a domain with no record yields none");
        $this->assertEqual(DmarcCheck::POLICY_NONE,
            $outcome['policy'],
            "and asks for no action");
    }
    /**
     * An empty From domain cannot be evaluated and yields none with
     * no action.
     */
    public function emptyFromDomainTestCase()
    {
        $outcome = DmarcCheck::evaluate('', DkimKey::VERIFY_PASS,
            'google.com', SpfCheck::PASS, 'google.com');
        $this->assertEqual(DmarcCheck::NONE, $outcome['result'],
            "an empty From domain yields none");
    }
}
X