*/ private array $originalEnvironment = []; private IndexerProxyDetector $detector; public function createApplication() { $this->databasePath = sys_get_temp_dir().'/nntmux-indexer-proxy-detector-test.sqlite'; $this->originalEnvironment = [ 'APP_ENV' => getenv('APP_ENV'), 'DB_CONNECTION' => getenv('DB_CONNECTION'), 'DB_DATABASE' => getenv('DB_DATABASE'), ]; if (file_exists($this->databasePath)) { unlink($this->databasePath); } $pdo = new PDO('sqlite:'.$this->databasePath); $pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)'); $pdo->exec("INSERT INTO settings (name, value) VALUES ('categorizeforeign', '0'), ('catwebdl', '0'), ('innerfileblacklist', '')"); $this->setEnvironmentValue('APP_ENV', 'testing'); $this->setEnvironmentValue('DB_CONNECTION', 'sqlite'); $this->setEnvironmentValue('DB_DATABASE', $this->databasePath); $app = require __DIR__.'/../../bootstrap/app.php'; $app->make(Kernel::class)->bootstrap(); return $app; } protected function setUp(): void { parent::setUp(); // Each test gets an isolated in-memory cache so sliding-window state // never leaks between cases. $this->detector = new IndexerProxyDetector(new Repository(new ArrayStore)); $this->configureDetector(); } protected function tearDown(): void { if ($this->databasePath !== '' && file_exists($this->databasePath)) { unlink($this->databasePath); } parent::tearDown(); foreach ($this->originalEnvironment as $key => $value) { $this->setEnvironmentValue($key, $value === false ? null : $value); } } public function test_disabled_detector_never_blocks_even_with_strong_signals(): void { $this->configureDetector(['enabled' => false]); $this->detector->recordSearch($this->searchContext( apiToken: 'tok', userAgent: 'NZBHydra2 8.3.0', clientIp: '10.0.0.1', )); $verdict = $this->detector->analyze($this->downloadContext( apiToken: 'tok', userAgent: 'NZBHydra2 8.3.0', clientIp: '10.0.0.1', referer: 'http://hydra.local:5076/getnzb/api', )); $this->assertFalse($verdict->shouldBlock); $this->assertSame(0, $verdict->score); $this->assertSame([], $verdict->reasons); } public function test_referer_matching_indexer_host_blocks(): void { // Isolate the Referer signal: no api_token (skips UA-pair + ratio) and a // fresh IP (skips IP correlation). $this->configureDetector(['threshold' => 1]); $verdict = $this->detector->analyze($this->downloadContext( apiToken: null, userAgent: 'SABnzbd/4.3.3', clientIp: '10.0.0.10', referer: 'http://prowlarr.local:9696/1/api', )); $this->assertTrue($verdict->shouldBlock); $this->assertSame(30, $verdict->score); $this->assertSame(['referer' => 30], $verdict->reasons); } public function test_referer_absent_does_not_block(): void { // Downloaders send no Referer — the redirected-grab shape. $verdict = $this->detector->analyze($this->downloadContext( apiToken: null, userAgent: 'SABnzbd/4.3.3', clientIp: '10.0.0.11', referer: null, )); $this->assertFalse($verdict->shouldBlock); $this->assertSame(0, $verdict->score); $this->assertSame([], $verdict->reasons); } public function test_same_ua_on_search_then_download_blocks(): void { // Isolate the UA-pair signal: search + download share the same token and // UA, but the download comes from a different IP so IP correlation stays // silent, and one search is below the ratio minimum. $this->configureDetector(['threshold' => 1]); $this->detector->recordSearch($this->searchContext( apiToken: 'tok', userAgent: 'NZBHydra2 8.3.0', clientIp: '10.0.0.1', )); $verdict = $this->detector->analyze($this->downloadContext( apiToken: 'tok', userAgent: 'NZBHydra2 8.3.0', clientIp: '10.0.0.2', referer: null, )); $this->assertTrue($verdict->shouldBlock); $this->assertSame(25, $verdict->score); $this->assertSame(['ua_pair' => 25], $verdict->reasons); } public function test_different_ua_on_search_then_download_allows(): void { // Redirected grab: Prowlarr searches, SABnzbd downloads from another IP. $this->detector->recordSearch($this->searchContext( apiToken: 'tok', userAgent: 'Prowlarr/2.0.0', clientIp: '10.0.0.1', )); $verdict = $this->detector->analyze($this->downloadContext( apiToken: 'tok', userAgent: 'SABnzbd/4.3.3', clientIp: '10.0.0.2', referer: null, )); $this->assertFalse($verdict->shouldBlock); $this->assertSame(0, $verdict->score); $this->assertSame([], $verdict->reasons); } public function test_high_download_to_search_ratio_blocks(): void { // Isolate the ratio signal: keep min-searches small, search/download with a // different UA and IP from the analyzed download so only the ratio fires. $this->configureDetector(['threshold' => 1, 'min_searches' => 2]); $this->detector->recordSearch($this->searchContext('tok', 'Prowlarr/2.0.0', '10.0.0.1')); $this->detector->recordSearch($this->searchContext('tok', 'Prowlarr/2.0.0', '10.0.0.1')); $this->detector->recordDownload($this->downloadContext('tok', 'Prowlarr/2.0.0', '10.0.0.1')); $this->detector->recordDownload($this->downloadContext('tok', 'Prowlarr/2.0.0', '10.0.0.1')); $verdict = $this->detector->analyze($this->downloadContext( apiToken: 'tok', userAgent: 'SABnzbd/4.3.3', clientIp: '10.0.0.9', referer: null, )); $this->assertTrue($verdict->shouldBlock); $this->assertSame(25, $verdict->score); $this->assertSame(['download_ratio' => 25], $verdict->reasons); } public function test_low_ratio_allows(): void { // Many searches, one download — the human shape. $this->configureDetector(['min_searches' => 2]); for ($i = 0; $i < 5; $i++) { $this->detector->recordSearch($this->searchContext('tok', 'Prowlarr/2.0.0', '10.0.0.1')); } $this->detector->recordDownload($this->downloadContext('tok', 'Prowlarr/2.0.0', '10.0.0.1')); $verdict = $this->detector->analyze($this->downloadContext( apiToken: 'tok', userAgent: 'SABnzbd/4.3.3', clientIp: '10.0.0.9', referer: null, )); $this->assertFalse($verdict->shouldBlock); $this->assertSame(0, $verdict->score); $this->assertSame([], $verdict->reasons); } public function test_ip_correlation_with_indexer_ua_blocks(): void { // Isolate the IP-correlation signal: no api_token (skips UA-pair + ratio), // but the same IP just searched with the same indexer UA. $this->configureDetector(['threshold' => 1]); $this->detector->recordSearch($this->searchContext( apiToken: null, userAgent: 'NZBHydra2 8.3.0', clientIp: '10.0.0.5', )); $verdict = $this->detector->analyze($this->downloadContext( apiToken: null, userAgent: 'NZBHydra2 8.3.0', clientIp: '10.0.0.5', referer: null, )); $this->assertTrue($verdict->shouldBlock); $this->assertSame(20, $verdict->score); $this->assertSame(['ip_correlation' => 20], $verdict->reasons); } public function test_score_below_threshold_allows(): void { // A single Referer signal (30) is below the default threshold of 50. $verdict = $this->detector->analyze($this->downloadContext( apiToken: null, userAgent: 'SABnzbd/4.3.3', clientIp: '10.0.0.12', referer: 'http://hydra.local:5076/getnzb/api', )); $this->assertFalse($verdict->shouldBlock); $this->assertSame(30, $verdict->score); $this->assertSame(['referer' => 30], $verdict->reasons); } public function test_search_recording_feeds_subsequent_download_analysis(): void { // Realistic NZBHydra2 direct fetch: one recorded search lets the later // download correlate on both UA-pair and IP, and the leaked Referer pushes // the combined score over the default threshold. $this->detector->recordSearch($this->searchContext( apiToken: 'tok', userAgent: 'NZBHydra2 8.3.0', clientIp: '10.0.0.1', )); $verdict = $this->detector->analyze($this->downloadContext( apiToken: 'tok', userAgent: 'NZBHydra2 8.3.0', clientIp: '10.0.0.1', referer: 'http://hydra.local:5076/getnzb/api', )); $this->assertTrue($verdict->shouldBlock); $this->assertSame(75, $verdict->score); $this->assertSame([ 'referer' => 30, 'ua_pair' => 25, 'ip_correlation' => 20, ], $verdict->reasons); } /** * @param array $overrides */ private function configureDetector(array $overrides = []): void { config()->set('nntmux.proxy_detection_enabled', $overrides['enabled'] ?? true); config()->set('nntmux.proxy_detection_threshold', $overrides['threshold'] ?? 50); config()->set('nntmux.proxy_detection_window_seconds', $overrides['window_seconds'] ?? 3600); config()->set('nntmux.proxy_detection_ratio_min', $overrides['ratio_min'] ?? 0.8); config()->set('nntmux.proxy_detection_min_searches', $overrides['min_searches'] ?? 20); config()->set( 'nntmux.proxy_detection_indexer_referer_patterns', $overrides['referer_patterns'] ?? 'hydra,prowlarr,jackett,lidarr,radarr,sonarr,readarr,bazarr', ); } private function searchContext(?string $apiToken, string $userAgent, string $clientIp, ?string $referer = null): ProxyRequestContext { return new ProxyRequestContext( clientIp: $clientIp, userAgent: $userAgent, apiToken: $apiToken, referer: $referer, isDownload: false, isSearch: true, ); } private function downloadContext(?string $apiToken, string $userAgent, string $clientIp, ?string $referer = null): ProxyRequestContext { return new ProxyRequestContext( clientIp: $clientIp, userAgent: $userAgent, apiToken: $apiToken, referer: $referer, isDownload: true, isSearch: false, ); } private function setEnvironmentValue(string $key, ?string $value): void { if ($value === null) { putenv($key); unset($_ENV[$key], $_SERVER[$key]); return; } putenv($key.'='.$value); $_ENV[$key] = $value; $_SERVER[$key] = $value; } }