/ tests / MessageComponentTest.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
 * <a href="https://www.gnu.org/licenses/">https://www.gnu.org/licenses/</a>
 *
 * 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;
require_once C\BASE_DIR . "/library/atto_servers/TurnSite.php";
use seekquarry\yioop\controllers\GroupController;
use seekquarry\atto\TimeLimitedTurnAuthenticator;
use seekquarry\yioop\library\UnitTest;
use seekquarry\yioop\models\GroupModel;
use seekquarry\yioop\models\ImpressionModel;

/**
 * MessageComponentTest reads the relays a browser is handed when it
 * cannot reach the other browser directly, which callRelays works out
 * from what the site runs and what its owner chose.
 *
 * A call between two people at home usually needs a relay, so a browser
 * handed an empty list, or one naming a relay that is not running,
 * rings and fails. Each case names the choice it stands for, since the
 * same site hands over different relays under each.
 *
 * @author Chris Pollett
 */
class MessageComponentTest extends UnitTest
{
    /**
     * $component holds the MessageComponent the cases below read,
     * reached through the group controller that carries it.
     * @var object
     */
    public $component;
    /**
     * setUp builds the component the cases below read. Nothing further is
     * set up, since callRelays is told what it needs rather than
     * reading a database.
     */
    public function setUp()
    {
        $parent = new GroupController();
        $this->component = $parent->component("message");
    }
    /**
     * tearDown has nothing to take down, since the cases write no file
     * and no database row.
     */
    public function tearDown()
    {
    }
    /**
     * noRelayAnywhereHandsNothingOverTestCase reads what a site hands
     * a browser when it runs no relay and its owner wrote none. The
     * list should be empty, and a call then has only the paths the two
     * browsers find for themselves. That is what a site does today,
     * and it is why two people at home cannot reach each other.
     */
    public function noRelayAnywhereHandsNothingOverTestCase()
    {
        $this->assertEqual([],
            $this->component->callRelays(null, false, "turnsite"),
            "a site with no relay hands a browser no relay");
        $this->assertEqual([],
            $this->component->callRelays([], false, "turnsite"),
            "and an empty list is the same as none");
    }
    /**
     * callingTurnedOffHandsNothingOverTestCase checks that a site owner
     * who turned calling off hands a browser no relay at all, whatever
     * the site runs and whatever was written by hand. It also checks
     * that the choice a site reports is always one of the four words
     * callRelays knows, since a site that saved nothing holds an empty
     * word for it and off is what that empty word means.
     */
    public function callingTurnedOffHandsNothingOverTestCase()
    {
        $by_hand = ["{'urls': 'turn:somewhere.example:3478'}"];
        $this->assertEqual([],
            $this->component->callRelays($by_hand, true, "off"),
            "calling turned off hands over no relay");
        $this->assertTrue(in_array($this->component->callServiceMode(),
            ["off", "turnsite", "external", "both"]),
            "the choice a site reports is one callRelays knows");
    }
    /**
     * runningRelayIsHandedOverTestCase checks that a relay the site
     * runs is handed to the browser, written as the JavaScript the
     * page gives RTCPeerConnection, with the address, the name and the
     * secret a browser needs.
     */
    public function runningRelayIsHandedOverTestCase()
    {
        $said = $this->component->callRelays(null, true, "turnsite");
        $this->assertEqual(2, count($said),
            "the site's own relay is handed over twice, to ask where a "
            . "browser is seen from and to carry what it sends");
        $this->assertTrue(str_contains($said[0], "stun:"),
            "the first entry names where a browser is seen from");
        $this->assertTrue(str_contains($said[1], "turn:"),
            "the second names a relay to turn to");
        $this->assertTrue(str_contains($said[1],
            ":" . intval(C\p('TURN_PORT'))),
            "on the port the relay was told to take");
        $this->assertTrue(str_contains($said[1], "'username'") &&
            str_contains($said[1], "'credential'"),
            "with the name and secret a browser gives it");
    }
    /**
     * relayWrittenByHandIsKeptTestCase reads the list where a site
     * owner wrote a relay by hand and the site runs one as well. Both
     * belong in it, the written one first, since a site may run its
     * own relay and still want another to fall back on. The written
     * one has to come back word for word, since it is JavaScript a
     * browser is given rather than something to be read and rebuilt.
     */
    public function relayWrittenByHandIsKeptTestCase()
    {
        $by_hand = ["{'urls': 'turn:somewhere.example:3478'}"];
        $said = $this->component->callRelays($by_hand, true, "turnsite");
        $this->assertEqual(3, count($said),
            "both the written relay and the site's own are handed over");
        $this->assertEqual($by_hand[0], $said[0],
            "the written one is handed over as it was written");
        $said = $this->component->callRelays($by_hand, false, "turnsite");
        $this->assertEqual($by_hand, $said,
            "and stands alone where the site runs no relay");
    }
    /**
     * tokenTheRelayAcceptsTestCase checks that the name and secret a
     * browser is handed are the pair the relay works out for itself
     * from the same key, so a call is admitted without a secret having
     * been written into the settings by hand. It also checks that a
     * token whose moment has passed is refused.
     */
    public function tokenTheRelayAcceptsTestCase()
    {
        if (C\p('TURN_SECRET') !== "") {
            $named = $this->component->callRelayToken();
            $this->assertEqual(C\p('TURN_SECRET'), $named["secret"],
                "a written secret is handed over as written");
            return;
        }
        $named = $this->component->callRelayToken();
        $relay = new TimeLimitedTurnAuthenticator(C\p('AUTH_KEY'));
        $said = $relay->lookupUser($named["name"]);
        $this->assertTrue($said !== false,
            "the relay recognizes the name the browser is handed");
        $this->assertEqual($named["secret"], $said["password"],
            "and expects the secret the browser was handed");
        $this->assertTrue($relay->lookupUser((time() - C\ONE_DAY) .
            ":yioop") === false,
            "a token whose moment has passed is refused");
        $other = new TimeLimitedTurnAuthenticator("another key");
        $this->assertTrue($other->lookupUser($named["name"])
            ["password"] !== $named["secret"],
            "a relay with another key expects another secret");
    }
    /**
     * Checks that being at the messages screen is what marks a contact as
     * there, rather than holding a sign-in that has not run out.
     */
    public function beingAtTheMessagesScreenMarksAContactTestCase()
    {
        $impression_model = new ImpressionModel();
        $here = 70201;
        $gone = 70202;
        $impression_model->add($here, 88101, C\GROUP_IMPRESSION);
        $impression_model->add($gone, 88102, C\GROUP_IMPRESSION,
            time() - 3600);
        $seen = $impression_model->lastSeenAtMessagesScreen(
            [$here => 88101, $gone => 88102]);
        $bands = C\MESSAGES_PRESENCE_BANDS;
        $this->assertTrue(!empty($seen[$here]) &&
            time() - $seen[$here] <= $bands['here'],
            "somebody at the screen now falls in the freshest band");
        $this->assertTrue(!empty($seen[$gone]) &&
            time() - $seen[$gone] > $bands['long'],
            "somebody who left an hour ago is past the stalest band");
        $this->assertEqual([], $impression_model->lastSeenAtMessagesScreen(
            []), "asking about nobody gives nothing back");
    }
    /**
     * Checks the phase a call is in after each step of placing, refusing,
     * answering and hanging up, which is what every screen reads to decide
     * whether to ring.
     */
    public function callPassesThroughItsPhasesTestCase()
    {
        $group_model = new GroupModel();
        $call_hash_id = "phase" . time();
        $this->assertEqual($group_model->callPhase($call_hash_id), 'none',
            "two people with no call between them are in no call");
        $group_model->createGroupCall($call_hash_id, 3);
        $group_model->addCallEvent($call_hash_id, 3, 'client-call', null);
        $this->assertEqual($group_model->callPhase($call_hash_id), 'ringing',
            "a call placed and not yet taken is ringing");
        $group_model->addCallEvent($call_hash_id, 1, 'client-answer', null);
        $this->assertEqual($group_model->callPhase($call_hash_id),
            'connected', "a call the other side has answered is connected");
        $group_model->addCallEvent($call_hash_id, 1, 'call-end', null);
        $group_model->deleteGroupCall($call_hash_id);
        $this->assertEqual($group_model->callPhase($call_hash_id), 'none',
            "a call either side has ended is over");
    }
    /**
     * Checks that a call refused and then placed again rings the second
     * time, which it did not while an ending left behind counted as a call
     * still going on.
     */
    public function callRefusedThenPlacedAgainRingsTestCase()
    {
        $group_model = new GroupModel();
        $call_hash_id = "again" . time();
        $group_model->createGroupCall($call_hash_id, 3);
        $group_model->addCallEvent($call_hash_id, 3, 'client-call', null);
        $group_model->addCallEvent($call_hash_id, 1, 'call-end', null);
        $group_model->deleteGroupCall($call_hash_id);
        $this->assertEqual($group_model->callPhase($call_hash_id), 'none',
            "the refused call is over");
        $group_model->createGroupCall($call_hash_id, 3);
        $this->assertEqual($group_model->callPhase($call_hash_id), 'ringing',
            "and the call placed straight after it rings");
        $this->assertTrue(!empty($group_model->callsRingingFor(
            [3 => $call_hash_id], 1)),
            "so the other side is told somebody is calling");
        $group_model->deleteGroupCall($call_hash_id);
    }
    /**
     * Checks that a call nobody answers stops ringing once the caller's
     * own screen has stopped, rather than ringing for as long as its row
     * sits in the table.
     */
    public function callNobodyAnswersStopsRingingTestCase()
    {
        $group_model = new GroupModel();
        $call_hash_id = "unanswered" . time();
        $group_model->createGroupCall($call_hash_id, 3);
        $this->assertEqual($group_model->callPhase($call_hash_id), 'ringing',
            "a call just placed is ringing");
        $group_model->refreshGroupCall($call_hash_id,
            microtime(true) - C\MAX_CALL_RING_TIME - 5);
        $this->assertEqual($group_model->callPhase($call_hash_id), 'none',
            "and past the time a caller rings for it is over");
        $group_model->deleteGroupCall($call_hash_id);
    }
    /**
     * Checks that the two words a browser shows the relay run out, rather
     * than being a lasting password anybody signed in could read off the
     * page and go on using.
     */
    public function relayWordsHandedToABrowserRunOutTestCase()
    {
        $words = $this->component->callRelayToken();
        $parts = explode(":", $words["name"], 2);
        $this->assertTrue(count($parts) == 2 && ctype_digit($parts[0]),
            "the name a browser is given says when it stops being good");
        $this->assertTrue(intval($parts[0]) > time(),
            "and that moment has not passed yet");
        $this->assertTrue($words["secret"] != C\p('TURN_SECRET'),
            "the secret handed over is not the site's own relay password");
    }
    /**
     * Checks that the two words a page hands a browser are the two words
     * the relay works out for itself, so a browser given them is let
     * through.
     */
    public function relayTakesTheWordsThePageHandsOutTestCase()
    {
        $words = $this->component->callRelayToken();
        $key = (C\p('TURN_SECRET') === "") ? C\p('AUTH_KEY') :
            C\p('TURN_SECRET');
        $relay = new TimeLimitedTurnAuthenticator($key);
        $wanted = $relay->lookupUser($words["name"]);
        $this->assertTrue(!empty($wanted),
            "the relay accepts the name the page hands out");
        $this->assertEqual($wanted['password'], $words["secret"],
            "and works out the same secret the page hands out");
        $this->assertTrue($relay->lookupUser("1:someone") === false,
            "a name whose moment has passed is refused");
    }
}
X