mirror of
https://github.com/Vateron-Media/XC_VM.git
synced 2026-10-03 04:02:10 +02:00
Port of Rosmi720/XC_VM@8f41d206. Harden the streaming user-info path against null/malformed data that caused a fatal TypeError on player login: - ImageUtils::validateURL() accepts null/empty (nullable icon columns) → ''. - UserRepository::decodeUserFields() tolerates already-decoded arrays, null columns and malformed JSON (via a shared decodeJsonField helper). - aggregateBouquetIds() skips non-array bouquet groups (mergeBouquetGroup helper); getStreamingUserInfo() resolves the bouquet map from the shared cache / DB when the caller passes none (resolveBouquets helper). - PlayerApiController::getOutputFormats() guards a non-iterable input and falls back to the full format set. Adapted to this tree: kept the existing null-safe category_map / active_cons handling, and split the added guards into small covered helpers to stay within the complexity ceiling (unit test for resolveBouquets).
147 lines
6.4 KiB
PHP
147 lines
6.4 KiB
PHP
<?php
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
use XcVm\Domain\User\UserRepository;
|
|
|
|
/**
|
|
* Unit tests for UserRepository::ispChanged() — the ISP-persist gate extracted
|
|
* from getStreamingUserInfo()/getUserInfo(). It guards the block that writes a
|
|
* looked-up ISP back to the line. A GeoIP lookup miss (empty con_isp_name) must
|
|
* NOT persist: the old code did, which read the undefined isp_asn key (the
|
|
* "Undefined array key isp_asn" warning) and stored a null isp_desc/as_number.
|
|
*/
|
|
final class UserRepositoryTest extends TestCase {
|
|
|
|
/** Invoke a private static UserRepository method via reflection. */
|
|
private function call(string $rMethod, ...$rArgs) {
|
|
$rM = new ReflectionMethod(UserRepository::class, $rMethod);
|
|
$rM->setAccessible(true);
|
|
return $rM->invoke(null, ...$rArgs);
|
|
}
|
|
|
|
public function testPersistsWhenIspDetectedAndChanged(): void {
|
|
$this->assertTrue(UserRepository::ispChanged('Comcast', 0, 'Verizon'));
|
|
}
|
|
|
|
public function testFirstDetectionAgainstNullDescPersists(): void {
|
|
// No ISP stored yet -> a detected ISP counts as changed.
|
|
$this->assertTrue(UserRepository::ispChanged('Comcast', 0, null));
|
|
}
|
|
|
|
// ── the bug: a GeoIP miss must not reach the persist branch ──
|
|
|
|
public function testGeoipMissDoesNotPersist(): void {
|
|
$this->assertFalse(UserRepository::ispChanged(null, 0, 'Verizon'), 'null con_isp_name');
|
|
$this->assertFalse(UserRepository::ispChanged('', 0, 'Verizon'), 'empty con_isp_name');
|
|
}
|
|
|
|
// ── no write when nothing actually changed ──
|
|
|
|
public function testUnchangedIspDoesNotPersist(): void {
|
|
$this->assertFalse(UserRepository::ispChanged('Comcast', 0, 'Comcast'));
|
|
$this->assertFalse(UserRepository::ispChanged('Comcast', 0, 'comcast'), 'case-insensitive');
|
|
$this->assertFalse(UserRepository::ispChanged('COMCAST', 0, 'comcast'));
|
|
}
|
|
|
|
// ── a line already flagged in violation is left alone ──
|
|
|
|
public function testViolationDoesNotPersist(): void {
|
|
$this->assertFalse(UserRepository::ispChanged('Comcast', 1, 'Verizon'));
|
|
}
|
|
|
|
// ── aggregateBouquetIds — locks the streams→channel_ids / channels→live_ids mapping ──
|
|
|
|
public function testAggregateBouquetIdsMapsAndDedupes(): void {
|
|
$rBouquets = array(
|
|
1 => array('streams' => array(10, 11), 'series' => array(20), 'channels' => array(30), 'movies' => array(40), 'radios' => array(50)),
|
|
2 => array('streams' => array(11, 12)), // 11 shared across bouquets
|
|
);
|
|
$rOut = $this->call('aggregateBouquetIds', array(1, 2), $rBouquets);
|
|
$this->assertSame(array(10, 11, 12), array_values($rOut['channel_ids']), 'streams -> channel_ids, unique');
|
|
$this->assertSame(array(20), array_values($rOut['series_ids']));
|
|
$this->assertSame(array(30), array_values($rOut['live_ids']), 'channels -> live_ids');
|
|
$this->assertSame(array(40), array_values($rOut['vod_ids']), 'movies -> vod_ids');
|
|
$this->assertSame(array(50), array_values($rOut['radio_ids']));
|
|
}
|
|
|
|
public function testAggregateBouquetIdsIgnoresMissingKeys(): void {
|
|
$rOut = $this->call('aggregateBouquetIds', array(1), array(1 => array('streams' => array(7))));
|
|
$this->assertSame(array(7), array_values($rOut['channel_ids']));
|
|
$this->assertSame(array(), $rOut['series_ids']);
|
|
$this->assertSame(array(), $rOut['radio_ids']);
|
|
}
|
|
|
|
// ── resolveCategoryIds ──
|
|
|
|
public function testResolveCategoryIdsUniqueAcrossBouquets(): void {
|
|
$rMap = array(1 => array(100, 101), 2 => array(101, 102));
|
|
$this->assertSame(array(100, 101, 102), $this->call('resolveCategoryIds', array(1, 2), $rMap));
|
|
}
|
|
|
|
public function testResolveCategoryIdsSkipsABouquetNewerThanTheMap(): void {
|
|
// Bouquet 16 was created after the heavy cache pass built the map.
|
|
$this->assertSame(array(100), $this->call('resolveCategoryIds', array(1, 16), array(1 => array(100))));
|
|
}
|
|
|
|
public function testStreamingUserInfoAcceptsNullBouquets(): void {
|
|
// player_api passes null unless it lists channels; the parameter must take it.
|
|
$rParam = (new ReflectionMethod(UserRepository::class, 'getStreamingUserInfo'))->getParameters()[2];
|
|
$this->assertSame('rBouquets', $rParam->getName());
|
|
$this->assertTrue($rParam->allowsNull());
|
|
}
|
|
|
|
// ── decodeUserFields ──
|
|
|
|
public function testDecodeUserFieldsNormalises(): void {
|
|
$rOut = $this->call('decodeUserFields', array(
|
|
'allowed_ips' => '["1.2.3.4"," 5.6.7.8 "]',
|
|
'allowed_ua' => 'null',
|
|
'bouquet' => '[1,2,3]',
|
|
'allowed_outputs' => '["1","2"]',
|
|
));
|
|
$this->assertSame(array(1, 2, 3), $rOut['bouquet']);
|
|
$this->assertSame(array('1.2.3.4', '5.6.7.8'), array_values($rOut['allowed_ips']), 'trimmed');
|
|
$this->assertSame(array(), $rOut['allowed_ua'], 'non-array json -> []');
|
|
$this->assertSame(array(1, 2), $rOut['allowed_outputs'], 'intval-mapped');
|
|
}
|
|
|
|
// ── verifyCachedCredentials ──
|
|
|
|
public function testVerifyTokenLookup(): void {
|
|
$rToken = str_repeat('a', 32);
|
|
$this->assertTrue($this->call('verifyCachedCredentials', array('access_token' => $rToken), null, $rToken, null));
|
|
$this->assertFalse($this->call('verifyCachedCredentials', array('access_token' => 'other'), null, $rToken, null));
|
|
}
|
|
|
|
public function testVerifyCredentialLookup(): void {
|
|
$rRow = array('username' => 'u', 'password' => 'p');
|
|
$this->assertTrue($this->call('verifyCachedCredentials', $rRow, null, 'u', 'p'));
|
|
$this->assertFalse($this->call('verifyCachedCredentials', $rRow, null, 'u', 'wrong'));
|
|
}
|
|
|
|
public function testVerifyIdLookupSkipsCheck(): void {
|
|
// An id-based lookup carries no username/password to re-verify.
|
|
$this->assertTrue($this->call('verifyCachedCredentials', array(), 5, null, null));
|
|
}
|
|
|
|
/**
|
|
* getLineById() is handed nullable columns (a MAG / Enigma2 device's
|
|
* `pair_id`, an activation code's `subscriber_id`) and raw request values.
|
|
* None of those is a line, and none may end the request: loading an
|
|
* unpaired MAG device used to throw, so deleting one answered an empty page.
|
|
*/
|
|
public function testGetLineByIdFindsNothingForANonId(): void {
|
|
foreach (array(null, '', 0, '0', -3, 'abc') as $rID) {
|
|
$this->assertNull(UserRepository::getLineById($rID), var_export($rID, true));
|
|
}
|
|
}
|
|
|
|
// A caller-supplied bouquet map is used as-is (the null path resolves from
|
|
// the shared cache / DB, exercised via the streaming endpoints).
|
|
public function testResolveBouquetsUsesCallerSuppliedMap(): void {
|
|
$rMap = array(1 => array('streams' => array(7)));
|
|
$this->assertSame($rMap, $this->call('resolveBouquets', $rMap));
|
|
$this->assertSame(array(), $this->call('resolveBouquets', array()));
|
|
}
|
|
}
|