/ tests / ApiUnsubscribeTest.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\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");
    }
}
X