<?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\SpfCheck;
use seekquarry\yioop\library\UnitTest;
/**
* Tests the network-free parts of the SPF evaluator: parsing
* mechanism terms (including the standard colon form, the tolerated
* equals form, and CIDR slashes), IPv4 and IPv6 range matching,
* qualifier-to-result mapping, and the guards on a bad client
* address. The DNS-walking mechanisms (a, mx, include, exists, and
* record retrieval) are exercised against live records by hand and
* are 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 SpfCheckTest 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 SpfCheck 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(SpfCheck::class, $method);
return $reflected->invokeArgs(null, $arguments);
}
/**
* A bare mechanism gets the default pass qualifier; an explicit
* qualifier is read off the front; a value after a colon is
* captured; an unknown mechanism word is rejected.
*/
public function parseMechanismTestCase()
{
$this->assertEqual(['+', 'mx', ''],
$this->call('parseMechanism', ['mx']),
"a bare mechanism defaults to the pass qualifier");
$this->assertEqual(['-', 'all', ''],
$this->call('parseMechanism', ['-all']),
"a leading minus is read as the fail qualifier");
$this->assertEqual(['+', 'ip4', '192.0.2.0/24'],
$this->call('parseMechanism', ['ip4:192.0.2.0/24']),
"an ip4 value after a colon is captured");
$this->assertTrue(
$this->call('parseMechanism', ['frobnicate']) === null,
"an unknown mechanism word is rejected");
}
/**
* A record that mistakenly writes ip4=address instead of
* ip4:address is still parsed, so a common typo does not become
* a permerror.
*/
public function tolerateEqualsFormTestCase()
{
$this->assertEqual(['+', 'ip4', '198.51.100.7'],
$this->call('parseMechanism', ['ip4=198.51.100.7']),
"ip4=address is accepted like ip4:address");
}
/**
* IPv4 range matching honors the CIDR prefix: an address inside
* the block matches and one outside does not, with the boundary
* at the prefix length.
*/
public function ipv4RangeTestCase()
{
$client = $this->call('parseIp', ['192.0.2.130']);
$this->assertTrue(
$this->call('matchIpMechanism',
['ip4', '192.0.2.0/24', $client]),
"an address inside a /24 matches");
$this->assertFalse(
$this->call('matchIpMechanism',
['ip4', '192.0.2.0/25', $client]),
"the same address is outside the lower /25");
$this->assertTrue(
$this->call('matchIpMechanism',
['ip4', '192.0.2.128/25', $client]),
"and inside the upper /25");
$this->assertTrue(
$this->call('matchIpMechanism',
['ip4', '192.0.2.130', $client]),
"an exact address with no prefix matches");
}
/**
* An ip4 mechanism never matches an IPv6 client and an ip6
* mechanism never matches an IPv4 client, and IPv6 prefixes are
* compared correctly.
*/
public function ipv6AndFamilyTestCase()
{
$client4 = $this->call('parseIp', ['192.0.2.130']);
$this->assertFalse(
$this->call('matchIpMechanism', ['ip6', '::/0',
$client4]),
"an ip6 mechanism does not match a v4 client");
$client6 = $this->call('parseIp', ['2001:db8::5']);
$this->assertTrue(
$this->call('matchIpMechanism',
['ip6', '2001:db8::/32', $client6]),
"a v6 address inside the /32 matches");
$this->assertFalse(
$this->call('matchIpMechanism',
['ip6', '2001:dc8::/32', $client6]),
"a v6 address outside the /32 does not match");
}
/**
* Each qualifier maps to its SPF result, and an invalid client
* address makes the top-level check return permerror.
*/
public function qualifierAndBadInputTestCase()
{
$this->assertEqual(SpfCheck::FAIL,
$this->call('qualifierResult', ['-']),
"minus maps to fail");
$this->assertEqual(SpfCheck::SOFTFAIL,
$this->call('qualifierResult', ['~']),
"tilde maps to softfail");
$this->assertEqual(SpfCheck::NEUTRAL,
$this->call('qualifierResult', ['?']),
"question maps to neutral");
$this->assertEqual(SpfCheck::PASS,
$this->call('qualifierResult', ['+']),
"plus maps to pass");
$this->assertEqual(SpfCheck::PERMERROR,
SpfCheck::check('not-an-ip', 'example.com'),
"a malformed client address is a permerror");
$this->assertEqual(SpfCheck::NONE,
SpfCheck::check('', 'example.com'),
"an empty client address yields none");
}
}