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

/**
 * Tests the signed unsubscribe-token logic: a freshly made token
 * parses back to the same member and group, and any change to the
 * ids, to the signature, or to the shape of the token is rejected.
 * These run network-free and depend only on the site's AUTH_KEY being
 * set, which the test configuration provides.
 *
 * @author Chris Pollett chris@pollett.org
 */
class UnsubscribeTokenTest 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()
    {
    }
    /**
     * A token made for a member and group parses back to exactly that
     * member and group.
     */
    public function roundTripTestCase()
    {
        $token = UnsubscribeToken::make(42, 7);
        $parsed = UnsubscribeToken::parse($token);
        $this->assertTrue(is_array($parsed),
            "a freshly made token parses to an array");
        $this->assertEqual(42, $parsed['user_id'],
            "round-trip recovers the user id");
        $this->assertEqual(7, $parsed['group_id'],
            "round-trip recovers the group id");
    }
    /**
     * Editing either id without re-signing makes the token fail, since
     * the signature no longer matches the ids it covers.
     */
    public function tamperedIdsTestCase()
    {
        $token = UnsubscribeToken::make(42, 7);
        $parts = explode(".", $token);
        $swapped = "43." . $parts[1] . "." . $parts[2];
        $this->assertFalse(UnsubscribeToken::parse($swapped),
            "changing the user id is rejected");
        $forged = $parts[0] . ".8." . $parts[2];
        $this->assertFalse(UnsubscribeToken::parse($forged),
            "changing the group id is rejected");
    }
    /**
     * A token whose signature is replaced with a made-up one is
     * rejected.
     */
    public function badSignatureTestCase()
    {
        $token = UnsubscribeToken::make(42, 7);
        $parts = explode(".", $token);
        $bad = $parts[0] . "." . $parts[1] . ".not-a-real-signature";
        $this->assertFalse(UnsubscribeToken::parse($bad),
            "a forged signature is rejected");
    }
    /**
     * Tokens of the wrong shape, or carrying non-numeric ids, are
     * rejected rather than throwing.
     */
    /**
     * The List-Unsubscribe headers carry both a web link and a mailto,
     * the one-click marker, and a token that parses back to the same
     * member and group.
     */
    public function listUnsubscribeHeadersTestCase()
    {
        $headers = UnsubscribeToken::listUnsubscribeHeaders(42, 7,
            "https://example.com/", "bot@example.com");
        $this->assertTrue(isset($headers['List-Unsubscribe']),
            "a List-Unsubscribe header is produced");
        $this->assertEqual("List-Unsubscribe=One-Click",
            $headers['List-Unsubscribe-Post'],
            "the one-click marker is set");
        $value = $headers['List-Unsubscribe'];
        $this->assertTrue(
            strpos($value, "c=api&a=unsubscribe&token=") !== false,
            "the web link points at the unsubscribe endpoint");
        $this->assertTrue(strpos($value, "mailto:bot@example.com") !== false,
            "the mailto link uses the unsubscribe address");
        $token = UnsubscribeToken::make(42, 7);
        $this->assertTrue(strpos($value, urlencode($token)) !== false,
            "the header carries the signed token");
        $parsed = UnsubscribeToken::parse($token);
        $this->assertEqual(42, $parsed['user_id'],
            "the carried token still names the member");
        $this->assertEqual(7, $parsed['group_id'],
            "the carried token still names the group");
    }
    /**
     * A whole-site token round-trips to the same address.
     */
    public function wholeSiteRoundTripTestCase()
    {
        $token = UnsubscribeToken::makeEmail("User@Example.com");
        $this->assertEqual("User@Example.com",
            UnsubscribeToken::parseEmail($token),
            "a whole-site token round-trips to the same address");
    }
    /**
     * A forged whole-site token is rejected: neither an altered
     * signature, nor a signature reused under a different address, nor a
     * plain unsigned address verifies, so an attacker cannot suppress an
     * address they were never handed a token for.
     */
    public function wholeSiteForgedRejectedTestCase()
    {
        $token = UnsubscribeToken::makeEmail("victim@example.com");
        $this->assertFalse(UnsubscribeToken::parseEmail($token . "x"),
            "a token with an altered signature is rejected");
        list($payload, $signature) = explode(".", $token);
        $other = rtrim(strtr(
            base64_encode("attacker@example.com"), "+/", "-_"), "=");
        $this->assertFalse(
            UnsubscribeToken::parseEmail($other . "." . $signature),
            "reusing a signature under a different address is rejected");
        $this->assertFalse(
            UnsubscribeToken::parseEmail("attacker@example.com"),
            "a plain address with no signature is rejected");
    }
    /**
     * The two token shapes do not cross over: a member-and-group token
     * is never read as a whole-site token, and a whole-site token is
     * never read as a member-and-group token.
     */
    public function wholeSiteVsGroupDistinctTestCase()
    {
        $group_token = UnsubscribeToken::make(42, 7);
        $this->assertFalse(UnsubscribeToken::parseEmail($group_token),
            "a member-and-group token is not a whole-site token");
        $email_token = UnsubscribeToken::makeEmail("user@example.com");
        $this->assertFalse(UnsubscribeToken::parse($email_token),
            "a whole-site token is not a member-and-group token");
    }
    /**
     * The whole-site List-Unsubscribe headers carry the one-click
     * marker and a token that parses back to the address, and the
     * matching web link points at the unsubscribe endpoint.
     */
    public function wholeSiteHeadersTestCase()
    {
        $headers = UnsubscribeToken::listUnsubscribeHeadersForEmail(
            "user@example.com", "https://example.com/",
            "bot@example.com");
        $this->assertEqual("List-Unsubscribe=One-Click",
            $headers['List-Unsubscribe-Post'],
            "the one-click marker is set");
        $token = UnsubscribeToken::makeEmail("user@example.com");
        $this->assertTrue(
            strpos($headers['List-Unsubscribe'],
            urlencode($token)) !== false,
            "the header carries the signed whole-site token");
        $url = UnsubscribeToken::unsubscribeUrl($token,
            "https://example.com/");
        $this->assertTrue(
            strpos($url, "c=api&a=unsubscribe&token=") !== false,
            "the web link points at the unsubscribe endpoint");
        $this->assertEqual("user@example.com",
            UnsubscribeToken::parseEmail($token),
            "the carried token still names the address");
    }
}
X