/ tests / MailRecordCacheTest.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\configs as C;
use seekquarry\yioop\library\mail\MailRecordCache;
use seekquarry\yioop\library\UnitTest;

/**
 * Tests the cross-user mail-security record cache: storing and
 * returning a record, keeping different record types for the same
 * name apart through the composite key, honoring expiry, clamping the
 * DNS time to live to the configured range, caching a miss separately
 * from an unknown name, and persisting to a file so the records are
 * shared across requests and users.
 *
 * Each case points the cache at a throwaway file under the system
 * temporary directory, set in setUp and removed in tearDown, so the
 * test never reads or writes the real CACHE_DIR.
 *
 * @author Chris Pollett
 */
class MailRecordCacheTest extends UnitTest
{
    /**
     * Throwaway file the cache persists to during a case; created
     * via setInstanceFile in setUp, removed in tearDown.
     * @var string
     */
    public $temp_file;
    /**
     * Points the shared cache at a unique throwaway file so nothing
     * touches the real CACHE_DIR.
     */
    public function setUp()
    {
        $this->temp_file = sys_get_temp_dir() . "/mail_record_test_" .
            getmypid() . "_" . uniqid() . ".json";
        MailRecordCache::setInstanceFile($this->temp_file);
    }
    /**
     * Removes the throwaway file and clears the shared instance so
     * nothing leaks into later tests or the real environment.
     */
    public function tearDown()
    {
        if (file_exists($this->temp_file)) {
            unlink($this->temp_file);
        }
        MailRecordCache::setInstanceFile(null);
    }
    /**
     * A type and name never stored returns null, telling the caller
     * to do a fresh DNS lookup; a stored record is returned on the
     * next get.
     */
    public function storeAndGetTestCase()
    {
        $cache = MailRecordCache::getInstance();
        $this->assertTrue(
            $cache->get(MailRecordCache::TYPE_DKIM,
                'sel._domainkey.unknown.example') === null,
            "an unknown record returns null");
        $cache->put(MailRecordCache::TYPE_DKIM,
            'sel._domainkey.known.example', 'KEYBODY', 60);
        $this->assertEqual('KEYBODY',
            $cache->get(MailRecordCache::TYPE_DKIM,
                'sel._domainkey.known.example'),
            "a stored record is returned");
    }
    /**
     * The same lookup name under two record types is kept apart by
     * the composite key, so a DKIM entry and a DMARC entry for one
     * domain do not overwrite or shadow each other.
     */
    public function compositeKeySeparatesTypesTestCase()
    {
        $cache = MailRecordCache::getInstance();
        $cache->put(MailRecordCache::TYPE_DKIM, 'example.com',
            'DKIMVALUE', 60);
        $cache->put(MailRecordCache::TYPE_DMARC, 'example.com',
            'v=DMARC1; p=reject', 60);
        $this->assertEqual('DKIMVALUE',
            $cache->get(MailRecordCache::TYPE_DKIM, 'example.com'),
            "the DKIM record for the name is returned");
        $this->assertEqual('v=DMARC1; p=reject',
            $cache->get(MailRecordCache::TYPE_DMARC, 'example.com'),
            "the DMARC record for the same name is separate");
    }
    /**
     * A lookup that found nothing is cached as the empty string,
     * which is distinct from the null returned for a record that was
     * never stored, so a missing record is not re-queried on every
     * view.
     */
    public function negativeLookupCachedTestCase()
    {
        $cache = MailRecordCache::getInstance();
        $cache->put(MailRecordCache::TYPE_DMARC,
            '_dmarc.nopolicy.example', '', 0);
        $this->assertTrue(
            $cache->get(MailRecordCache::TYPE_DMARC,
                '_dmarc.nopolicy.example') === '',
            "a cached miss returns '' not null");
    }
    /**
     * The DNS record's time to live is honored but clamped: a tiny
     * value is raised to the configured minimum and a huge value is
     * lowered to the configured maximum, read back from the stored
     * expiry.
     */
    public function timeToLiveClampedTestCase()
    {
        $cache = MailRecordCache::getInstance();
        $now = time();
        $cache->put(MailRecordCache::TYPE_DKIM, 'low.example', 'K',
            1);
        $cache->put(MailRecordCache::TYPE_DKIM, 'high.example', 'K',
            999999999);
        $stored = json_decode(file_get_contents($this->temp_file),
            true);
        $low_key = MailRecordCache::TYPE_DKIM . "\x00" . 'low.example';
        $high_key = MailRecordCache::TYPE_DKIM . "\x00" .
            'high.example';
        $low_life = $stored[$low_key][1] - $now;
        $high_life = $stored[$high_key][1] - $now;
        $this->assertTrue(
            $low_life >= C\MAIL_RECORD_CACHE_MIN - 2 &&
            $low_life <= C\MAIL_RECORD_CACHE_MIN + 2,
            "a short DNS ttl is clamped up to the minimum");
        $this->assertTrue(
            $high_life >= C\MAIL_RECORD_CACHE_MAX - 2 &&
            $high_life <= C\MAIL_RECORD_CACHE_MAX + 2,
            "a long DNS ttl is clamped down to the maximum");
    }
    /**
     * An entry whose expiry is in the past is not returned, so a
     * changed or stale record is eventually re-fetched from DNS.
     */
    public function expiredEntryIgnoredTestCase()
    {
        $key = MailRecordCache::TYPE_DKIM . "\x00" . 'old.example';
        $expired = [$key => ['STALE', time() - 10]];
        file_put_contents($this->temp_file, json_encode($expired));
        MailRecordCache::setInstanceFile($this->temp_file);
        $cache = MailRecordCache::getInstance();
        $this->assertTrue(
            $cache->get(MailRecordCache::TYPE_DKIM, 'old.example')
                === null,
            "an expired entry is treated as a miss");
    }
    /**
     * A record stored by one instance is read back by a fresh
     * instance loaded from the same file, which is how the cache is
     * shared across separate requests and users.
     */
    public function persistsAcrossInstancesTestCase()
    {
        $cache = MailRecordCache::getInstance();
        $cache->put(MailRecordCache::TYPE_DKIM, 'shared.example',
            'SHAREDKEY', 60);
        MailRecordCache::setInstanceFile($this->temp_file);
        $fresh = MailRecordCache::getInstance();
        $this->assertEqual('SHAREDKEY',
            $fresh->get(MailRecordCache::TYPE_DKIM, 'shared.example'),
            "a fresh instance loads the record from the file");
    }
}
X