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