<?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");
}
}