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