<?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\controllers\ApiController;
use seekquarry\yioop\library\UnitTest;
use seekquarry\yioop\library\mail\UnsubscribeToken;
use seekquarry\yioop\models\GroupModel;
use seekquarry\yioop\models\ProfileModel;
use seekquarry\yioop\models\datasources\Sqlite3Manager;
use seekquarry\yioop\views\UnsubscribeView;
/**
* Tests the ApiController unsubscribe endpoint: that an invalid link is
* rejected, that a plain visit only offers a confirmation (so link
* scanners cannot unsubscribe anyone), and that a one-click request
* actually turns the group's email off for the user named by the signed
* link. Also checks the confirm form repeats the token and the one-click
* marker.
*
* @author Chris Pollett
*/
class ApiUnsubscribeTest extends UnitTest
{
/**
* Throwaway SQLite database holding the test membership row
* @var object
*/
public $db;
/**
* File path of the throwaway database
* @var string
*/
public $db_path;
/**
* GroupModel pointed at the throwaway database
* @var object
*/
public $group_model;
/**
* ApiController under test, with the throwaway GroupModel injected
* @var object
*/
public $controller;
/**
* Stub suppression list that records the addresses suppressed
* @var object
*/
public $suppression;
/**
* User id used for the seeded membership row
* @var int
*/
public $user_id = 990001;
/**
* Group id used for the seeded membership row
* @var int
*/
public $group_id = 990002;
/**
* Builds a throwaway database with a single subscribed membership row
* and an ApiController wired to use it.
*/
public function setUp()
{
$tag = uniqid();
$this->db_path = C\WORK_DIRECTORY . "/temp/api_unsub_test_$tag.db";
$this->db = new Sqlite3Manager();
$this->db->connect("", "", "", $this->db_path);
/* Build the tables from ProfileModel's definitions, the same ones the
live database uses, so this test tracks any schema change rather
than hand-copied CREATE TABLEs that could drift. */
$dbinfo = ["DBMS" => "Sqlite3", "DB_HOST" => ""];
$profile = new ProfileModel(C\DB_NAME, false);
$profile->initializeSql($this->db, $dbinfo);
foreach (['USER_GROUP', 'SOCIAL_GROUPS'] as $table) {
$this->db->execute($profile->create_statements[$table]);
}
$this->db->execute("INSERT INTO SOCIAL_GROUPS (GROUP_ID, " .
"GROUP_NAME) VALUES (?, ?)", [$this->group_id, "Test Group"]);
$this->group_model = new GroupModel(C\DB_NAME, false);
$this->group_model->db = $this->db;
$this->group_model->addUserGroup($this->user_id, $this->group_id);
$this->group_model->setMailSubscription($this->user_id,
$this->group_id, 1);
$_SESSION = [];
$_REQUEST = [];
$this->controller = new ApiController(null);
$this->controller->model_instances['group'] = $this->group_model;
$this->suppression = new class {
/**
* Addresses that suppress() has been called with.
* @var array
*/
public $suppressed = [];
/**
* Records a whole-site suppression request.
*
* @param string $email address to suppress
*/
public function suppress($email)
{
$this->suppressed[] = $email;
}
};
$this->controller->model_instances['mailSuppression'] =
$this->suppression;
}
/**
* Disconnects and removes the throwaway database.
*/
public function tearDown()
{
if ($this->db) {
$this->db->disconnect();
}
if ($this->db_path && file_exists($this->db_path)) {
unlink($this->db_path);
}
$_REQUEST = [];
}
/**
* Runs the unsubscribe activity for a token, optionally carrying the
* one-click marker, and returns the data the view would be drawn
* from.
*
* @param string $token signed unsubscribe token to place on the
* request
* @param bool $one_click whether to carry the one-click marker that a
* mail client (or the confirm button) sends
* @return array the data the unsubscribe activity returns
*/
public function runUnsubscribe($token, $one_click)
{
$_REQUEST = ['token' => $token];
if ($one_click) {
$_REQUEST['List-Unsubscribe'] = "One-Click";
}
return $this->controller->unsubscribe();
}
/**
* A link whose token does not verify is marked invalid and changes
* nothing.
*/
public function invalidTokenTestCase()
{
$data = $this->runUnsubscribe("not.a.realtoken", false);
$this->assertEqual("unsubscribe", $data['VIEW'],
"the unsubscribe view is selected");
$this->assertEqual("invalid", $data['UNSUBSCRIBE_STATE'],
"a bad token is reported invalid");
$this->assertEqual(1, $this->group_model->getMailSubscription(
$this->user_id, $this->group_id),
"an invalid link leaves the subscription on");
}
/**
* A plain visit with a valid token only asks for confirmation and
* does not unsubscribe by itself.
*/
public function confirmStateTestCase()
{
$token = UnsubscribeToken::make($this->user_id, $this->group_id);
$data = $this->runUnsubscribe($token, false);
$this->assertEqual("confirm", $data['UNSUBSCRIBE_STATE'],
"a plain visit asks for confirmation");
$this->assertEqual("Test Group", $data['GROUP_NAME'],
"the group name is supplied for the prompt");
$this->assertEqual(1, $this->group_model->getMailSubscription(
$this->user_id, $this->group_id),
"a plain visit does not unsubscribe");
}
/**
* A one-click request with a valid token turns the group's mail off
* for that user.
*/
public function oneClickUnsubscribesTestCase()
{
$token = UnsubscribeToken::make($this->user_id, $this->group_id);
$data = $this->runUnsubscribe($token, true);
$this->assertEqual("done", $data['UNSUBSCRIBE_STATE'],
"a one-click request reports done");
$this->assertEqual(0, $this->group_model->getMailSubscription(
$this->user_id, $this->group_id),
"a one-click request turns the subscription off");
}
/**
* The confirm page draws a form that repeats the token and the
* one-click marker; the done page draws no form.
*/
public function confirmFormCarriesTokenTestCase()
{
$token = UnsubscribeToken::make($this->user_id, $this->group_id);
$confirm = $this->runUnsubscribe($token, false);
$view = new UnsubscribeView();
ob_start();
$view->renderView($confirm);
$page = ob_get_clean();
$this->assertTrue(strpos($page, "<form") !== false,
"the confirm page has a form");
$this->assertTrue(strpos($page, $token) !== false,
"the form repeats the signed token");
$this->assertTrue(
strpos($page, 'name="List-Unsubscribe"') !== false,
"the form carries the one-click marker");
$done = $this->runUnsubscribe($token, true);
ob_start();
$view->renderView($done);
$done_page = ob_get_clean();
$this->assertTrue(strpos($done_page, "<form") === false,
"the done page has no form");
}
/**
* A plain visit with a whole-site (email) token asks for
* confirmation, names the address, and suppresses nothing yet.
*/
public function wholeSiteConfirmTestCase()
{
$token = UnsubscribeToken::makeEmail("person@example.com");
$data = $this->runUnsubscribe($token, false);
$this->assertEqual("all", $data['UNSUBSCRIBE_SCOPE'],
"an email token is recognised as a whole-site request");
$this->assertEqual("confirm", $data['UNSUBSCRIBE_STATE'],
"a plain visit asks for confirmation");
$this->assertEqual("person@example.com",
$data['UNSUBSCRIBE_EMAIL'],
"the address is supplied for the prompt");
$this->assertEqual(0, count($this->suppression->suppressed),
"a plain visit suppresses nothing");
}
/**
* A one-click whole-site request adds the address to the suppression
* list.
*/
public function wholeSiteOneClickSuppressesTestCase()
{
$token = UnsubscribeToken::makeEmail("person@example.com");
$data = $this->runUnsubscribe($token, true);
$this->assertEqual("done", $data['UNSUBSCRIBE_STATE'],
"a one-click request reports done");
$this->assertEqual(["person@example.com"],
$this->suppression->suppressed,
"the address is suppressed site-wide");
}
}