Files
XC_VM/tests/Unit/ScanMinorFixesTest.php
T
obscuremindandClaude Opus 4.8 3f13a40134 fix(panel): guard $rServers reads when a server is absent from the map
A server id missing from the in-memory $rServers map — a stale or deleted
id in an admin bulk action, or SERVER_ID itself on a node whose map was not
fully seeded — turned every `$rServers[<id>][...]` read into an "Undefined
array key N" + "Trying to access array offset on null" pair in the panel
error log. Guard the reads the logs flagged, plus the identical sibling
reads that share the fault:

- ServerRepository::getPublicURL and the MultiAjaxController bulk delete:
  !empty() before dereferencing an arbitrary / user-supplied server id.
- The stream endpoints' time_offset reads (vod, timeshift, segment, rtmp,
  thumb, subtitle) and ShutdownHandler: `?? 0`, matching the guard
  UsersCronJob already used — an absent SERVER_ID row means no clock offset.
- WatchdogCommand / RootSignalsCronJob / UsersCronJob is_main and the
  RootSignals nginx limit reads: `?? 0` (absent own row = not main / 0).
- MovieService import: guard $rParts and a null listDir()['files'].

Behaviour is unchanged where the id is present; the warnings stop where it
is not. A source-assertion regression test is added, and the pre-existing
pins that named the bare forms are updated to the guarded shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DzxNdtqERzxmFNR6jVAoPw
2026-10-15 23:09:25 +00:00

101 lines
5.5 KiB
PHP

<?php
use PHPUnit\Framework\TestCase;
use XcVm\Public\Controllers\Admin\Ajax\DeviceAjaxController;
/**
* Small fixes from the whole-code scan whose code runs only inside a request
* or a shell: each is pinned here by what it must still say.
*/
final class ScanMinorFixesTest extends TestCase {
private static function source(string $rPath): string {
return (string) file_get_contents(MAIN_HOME . $rPath);
}
/** One row per stream and per line: without GROUP BY every viewer was credited to one of them. */
public function testTheSearchCountsViewersPerStreamAndPerLine(): void {
$rSource = self::source('Public/Controllers/Admin/Ajax/SearchAjaxController.php');
$this->assertStringContainsString("AND `hls_end` = 0 GROUP BY `stream_id`;", $rSource);
$this->assertStringContainsString("AND `hls_end` = 0 GROUP BY `user_id`;", $rSource);
}
/** grep read bare brackets as a character class: the pattern never matched the title "Record[12]". */
public function testDeletingARecordingFindsItsProcess(): void {
$this->assertStringContainsString("grep 'Record\\\\[\" . intval(\$rID) . \"\\\\]'", self::source('Domain/Stream/RecordingService.php'));
exec("printf 'php Record[12] x\\nRecord1\\n' | grep -c 'Record\\[12\\]'", $rOut);
$this->assertSame(['1'], $rOut);
}
public function testTheRtmpShutdownSeesTheDatabaseHandle(): void {
$rSource = self::source('Public/stream/rtmp.php');
$rShutdown = substr($rSource, (int) strpos($rSource, 'function shutdown()'), 400);
$this->assertStringContainsString('global $db;', $rShutdown);
}
/** The refusal was read after it had been taken out of the listing: every refused directory was a success. */
public function testARefusedDirectoryIsAFailure(): void {
$rSource = self::source('Public/Controllers/Api/AdminAPIWrapper.php');
$this->assertLessThan(strpos($rSource, "unset(\$rData['result']);"), strpos($rSource, "\$rRefused = isset(\$rData['result']) && !\$rData['result'];"));
}
public function testThePublicUrlReadsTheHostConstant(): void {
$rSource = self::source('Domain/Server/ServerRepository.php');
$this->assertStringContainsString("defined('HOST') ? HOST : null", $rSource);
$this->assertStringNotContainsString("defined('host')", $rSource);
}
/** Deleting was the answer to every bulk server action the handler did not name. */
public function testOnlyDeleteDeletesServers(): void {
$rSource = self::source('Public/Controllers/Admin/Ajax/MultiAjaxController.php');
$this->assertMatchesRegularExpression('/\} elseif \(\$rSub == \'delete\'\) \{\s+foreach \(\$rRequestIDs as \$rServerID\) \{\s+if \(!empty\(\$rServers\[\$rServerID\]\) && \$rServers\[\$rServerID\]\[\'is_main\'\] == 0\) \{\s+ServerRepository::deleteById/', $rSource);
}
public function testTheGuideReadsTheChannelsArchiveWindow(): void {
$rSource = self::source('Public/Controllers/Player/ListingsController.php');
$this->assertStringNotContainsString("\$rEPGItem['tv_archive_duration']", $rSource);
}
public function testRelatedMoviesMatchTheCategoryList(): void {
$this->assertStringContainsString("WHERE JSON_CONTAINS(`category_id`, ?, \\'$\\') AND `type` = 2", self::source('Public/Controllers/PlayerV2/PlayerWatchController.php'));
}
public function testAMissingUserIsNotTheAddForm(): void {
$this->assertStringContainsString("if (RequestManager::has('id') && \$rUser === null) {", self::source('Public/Controllers/Admin/UserController.php'));
}
/** Decoded before it is cleaned: cleaned first, a message with a quote in it did not decode and was dropped. */
public function testAMagMessageKeepsItsQuotes(): void {
$rEvent = DeviceAjaxController::eventData((string) json_encode(['id' => '5', 'type' => 'send_msg', 'message' => 'He said "hi" <script>x']));
$this->assertSame('He said "hi" &#60;script>x', $rEvent['message']);
$this->assertNull(DeviceAjaxController::eventData('not json'));
$this->assertNull(DeviceAjaxController::eventData('{"message":"no id"}'));
}
/** A quote in a source's path ended it there: the concat list did not parse and the channel never started. */
public function testACreatedChannelsListQuotesItsPaths(): void {
$this->assertSame("file '/mnt/It'\\''s a film.mkv'\n", \XcVm\Cli\Commands\CreatedCommand::concatLine("/mnt/It's a film.mkv"));
$this->assertSame("file '/mnt/a.mkv'\n", \XcVm\Cli\Commands\CreatedCommand::concatLine('/mnt/a.mkv'));
}
/** Only the stream's own directory: a recording's "<id>.ts" beside it was removed as that stream's archive. */
public function testArchiveCleanupTakesTheStreamsDirectoryOnly(): void {
$rSource = self::source('Cli/CronJobs/CleanupCronJob.php');
$this->assertStringContainsString('basename($rStreamID) === (string) $rID && is_dir($rStreamID)', $rSource);
$this->assertStringContainsString("ProcessRunner::run(['rm', '-rf', '--', \$rStreamID], true);", $rSource);
}
/** Shown as stored, "s:<server>:<path>": shown without it and posted back, an edit lost the movie's server. */
public function testAMovieEditKeepsItsSourceAsStored(): void {
$rSource = self::source('Public/Controllers/Admin/MovieController.php');
$this->assertStringNotContainsString('urldecode($parts[2])', $rSource);
$this->assertStringContainsString('$rPathSources = $rSource;', $rSource);
}
/** file_get_contents() over https does not work under PHP-FPM here: the trailer and stills lookups use cURL. */
public function testTmdbLookupsUseCurl(): void {
foreach (['Domain/Vod/TMDbService.php', 'Domain/Vod/VodItemImporter.php'] as $rFile) {
$this->assertStringNotContainsString('file_get_contents($rURL)', self::source($rFile), $rFile);
}
}
}