mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-29 02:01:33 +00:00
431 lines
18 KiB
PHP
431 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Unit;
|
|
|
|
use App\Models\Category;
|
|
use App\Services\Categorization\CategorizationResult;
|
|
use App\Services\Categorization\Categorizers\MiscCategorizer;
|
|
use App\Services\Categorization\Pipes\BookPipe;
|
|
use App\Services\Categorization\Pipes\CategorizationPassable;
|
|
use App\Services\Categorization\Pipes\ConsolePipe;
|
|
use App\Services\Categorization\Pipes\GroupNamePipe;
|
|
use App\Services\Categorization\Pipes\MiscPipe;
|
|
use App\Services\Categorization\Pipes\MiscSafetyNetPipe;
|
|
use App\Services\Categorization\Pipes\MoviePipe;
|
|
use App\Services\Categorization\Pipes\MusicPipe;
|
|
use App\Services\Categorization\Pipes\PcPipe;
|
|
use App\Services\Categorization\Pipes\TvPipe;
|
|
use App\Services\Categorization\Pipes\XxxPipe;
|
|
use App\Services\Categorization\ReleaseContext;
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
class HashedReleaseCategorizationTest extends TestCase
|
|
{
|
|
/**
|
|
* The ordered list of pipes that matches CategorizationPipeline::createDefault().
|
|
* Sorted by priority: MiscPipe(1), GroupNamePipe(5), XxxPipe(10), TvPipe(20),
|
|
* MoviePipe(25), BookPipe, MusicPipe, PcPipe, ConsolePipe, MiscSafetyNetPipe.
|
|
*
|
|
* @return list<object>
|
|
*/
|
|
private function buildPipes(): array
|
|
{
|
|
return [
|
|
new MiscPipe,
|
|
new GroupNamePipe,
|
|
new XxxPipe,
|
|
new TvPipe,
|
|
new MoviePipe,
|
|
new BookPipe,
|
|
new MusicPipe,
|
|
new PcPipe,
|
|
new ConsolePipe,
|
|
new MiscSafetyNetPipe,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Run a release name through the full pipe chain and return the passable.
|
|
*/
|
|
private function runPipeline(string $releaseName, string $groupName = ''): CategorizationPassable
|
|
{
|
|
$context = new ReleaseContext(
|
|
releaseName: $releaseName,
|
|
groupId: 0,
|
|
groupName: $groupName,
|
|
poster: '',
|
|
);
|
|
|
|
$passable = new CategorizationPassable($context, debug: true);
|
|
$pipes = $this->buildPipes();
|
|
|
|
// Manually run through each pipe in order (avoids needing Laravel app container)
|
|
foreach ($pipes as $pipe) {
|
|
$passable = $pipe->handle($passable, fn ($p) => $p);
|
|
}
|
|
|
|
return $passable;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Data providers
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Hashed release names that MUST end up in OTHER_HASHED.
|
|
*
|
|
* @return array<string, array{0: string, 1: string}>
|
|
*/
|
|
public static function hashedNamesProvider(): array
|
|
{
|
|
return [
|
|
'MD5 hash' => ['d41d8cd98f00b204e9800998ecf8427e', 'hash_md5'],
|
|
'MD5 hash with quotes' => ['"d41d8cd98f00b204e9800998ecf8427e"', 'hash_md5'],
|
|
'SHA-1 hash' => ['da39a3ee5e6b4b0d3255bfef95601890afd80709', 'hash_sha1'],
|
|
'SHA-256 hash' => ['e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', 'hash_sha256'],
|
|
'UUID with dashes' => ['550e8400-e29b-41d4-a716-446655440000', 'hash_uuid'],
|
|
'Pure hex 20 chars' => ['aabbccdd0011223344ff', 'hash_hex'],
|
|
'All uppercase 20 chars' => ['ABCDEFGH1234567890XY', 'obfuscated_uppercase'],
|
|
'Mixed alphanumeric random' => ['AA7Jl2toE8Q53yNZmQ5R6G', 'obfuscated_mixed_alphanumeric'],
|
|
'Usenet obfuscated filename' => ['[01/10] - "xK9mR2pL4qW7nT3vB.part01.rar"', 'obfuscated_usenet_filename'],
|
|
'Base64-like token' => ['VGhpc0lzTm90QVNob3dOYW1lMTIzNDU2Nzg5MA==', 'hash_base64_like'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Gibberish release names that MUST end up in OTHER_HASHED.
|
|
*
|
|
* @return array<string, array{0: string, 1: string}>
|
|
*/
|
|
public static function gibberishNamesProvider(): array
|
|
{
|
|
return [
|
|
// Contains dots and lowercase letters — bypasses obfuscated_uppercase (needs ^[A-Z0-9]),
|
|
// obfuscated_mixed_alphanumeric (needs ^[a-zA-Z0-9]{15,}$ — dots break it),
|
|
// and obfuscated_punctuation (needs all uppercase alpha).
|
|
// After stripping separators, coreName has high character-transition rate.
|
|
'Random transitions' => ['aB3c.D4eF.5gH6i.J7kL8m', 'gibberish_random_transitions'],
|
|
// Lowercase with dots and digits — bypasses all obfuscated checks.
|
|
// After stripping, coreName ≥20 with maxConsecutiveLetters < 5 but low transition rate.
|
|
// Grouped letter/digit runs keep transition rate ≤ 0.35.
|
|
'No word structure long' => ['xyz.1234.wvut.5678.srqp.9012.rar', 'gibberish_no_word_structure'],
|
|
// Lowercase with dots and digits — bypasses all obfuscated checks.
|
|
// After stripping, coreName matches digit-heavy pattern (1-3 letters + 6+ digits).
|
|
'Digit-heavy pattern' => ['xz.123456789012', 'gibberish_random_digits'],
|
|
'Zero vowel core' => ['xkcdqwrtypsdfghjklmnbvcxz', 'gibberish_zero_vowels'],
|
|
'No signal long token' => ['A1b2.C3d4.E5f6.G7h8.I9j0', 'gibberish_no_signal'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Legitimate release names that MUST NOT be locked to misc.
|
|
*
|
|
* @return array<string, array{0: string, 1: string}>
|
|
*/
|
|
public static function legitimateNamesProvider(): array
|
|
{
|
|
return [
|
|
'Movie release' => ['Some.Movie.2024.1080p.BluRay.x264-GROUP', 'alt.binaries.movies'],
|
|
'TV episode' => ['Show.Name.S03E05.720p.HDTV.x264-GROUP', 'alt.binaries.hdtv'],
|
|
'Music album' => ['Artist.Name-Album.Title-2024-FLAC-GROUP', 'alt.binaries.sounds.mp3'],
|
|
'Game release' => ['Starfield-RUNE', 'alt.binaries.games'],
|
|
'Readable software package' => ['Microsoft Office Suite Installer', 'alt.binaries.warez'],
|
|
'Adobe msix bundle' => ['Adobe Express Photos.Msixbundle', 'alt.binaries.erotica.divx'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Readable software-like names that must not be classified as hashed by the misc categorizer.
|
|
*
|
|
* @return array<string, array{0: string}>
|
|
*/
|
|
public static function readableSoftwareNamesProvider(): array
|
|
{
|
|
return [
|
|
'Adobe msix bundle' => ['Adobe Express Photos.Msixbundle'],
|
|
'Office installer words' => ['Microsoft Office Suite Installer'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Hashed names paired with group names that would otherwise
|
|
* categorize the release into a content category.
|
|
*
|
|
* @return array<string, array{0: string, 1: string}>
|
|
*/
|
|
public static function hashedWithGroupProvider(): array
|
|
{
|
|
return [
|
|
'MD5 in movies group' => ['d41d8cd98f00b204e9800998ecf8427e', 'alt.binaries.movies'],
|
|
'Random in hdtv group' => ['AA7Jl2toE8Q53yNZmQ5R6G', 'alt.binaries.hdtv'],
|
|
'SHA1 in music group' => ['da39a3ee5e6b4b0d3255bfef95601890afd80709', 'alt.binaries.sounds.mp3'],
|
|
'Uppercase in xxx group' => ['ABCDEFGH1234567890XY', 'alt.binaries.erotica'],
|
|
'Gibberish in games group' => ['aB3cD4eF5gH6iJ7kL8m', 'alt.binaries.games'],
|
|
'Hex in warez group' => ['aabbccdd0011223344ff', 'alt.binaries.warez'],
|
|
'Random digits in ebook group' => ['ab12345678901234', 'alt.binaries.e-book'],
|
|
'Base64-like token in TV group' => ['VGhpc0lzTm90QVNob3dOYW1lMTIzNDU2Nzg5MA==', 'alt.binaries.hdtv'],
|
|
'Zero vowel token in movie group' => ['xkcdqwrtypsdfghjklmnbvcxz', 'alt.binaries.movies'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Low-signal names that are not strong enough for an early lock but should
|
|
* still be kept out of content categories when only the group matched.
|
|
*
|
|
* @return array<string, array{0: string, 1: string}>
|
|
*/
|
|
public static function lowSignalGroupOnlyProvider(): array
|
|
{
|
|
return [
|
|
'Short low-signal token in TV group' => ['ab12.cd34.ef56', 'alt.binaries.hdtv'],
|
|
'Short low-signal token in movies group' => ['zx90.cv78.bn56', 'alt.binaries.movies'],
|
|
];
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Tests: MiscCategorizer (unit-level)
|
|
// ------------------------------------------------------------------
|
|
|
|
#[DataProvider('hashedNamesProvider')]
|
|
public function test_misc_categorizer_detects_hashed_names(string $name, string $expectedMatchedBy): void
|
|
{
|
|
$categorizer = new MiscCategorizer;
|
|
$context = new ReleaseContext(releaseName: $name, groupId: 0);
|
|
$result = $categorizer->categorize($context);
|
|
|
|
$this->assertTrue($result->isSuccessful(), "Expected successful match for: $name");
|
|
$this->assertSame($expectedMatchedBy, $result->matchedBy, "Wrong matchedBy tag for: $name");
|
|
$this->assertContains(
|
|
$result->categoryId,
|
|
[Category::OTHER_HASHED, Category::OTHER_MISC],
|
|
"Expected misc category for: $name"
|
|
);
|
|
}
|
|
|
|
#[DataProvider('gibberishNamesProvider')]
|
|
public function test_misc_categorizer_detects_gibberish_names(string $name, string $expectedMatchedBy): void
|
|
{
|
|
$categorizer = new MiscCategorizer;
|
|
$context = new ReleaseContext(releaseName: $name, groupId: 0);
|
|
$result = $categorizer->categorize($context);
|
|
|
|
$this->assertTrue($result->isSuccessful(), "Expected successful match for: $name");
|
|
$this->assertSame($expectedMatchedBy, $result->matchedBy, "Wrong matchedBy tag for: $name");
|
|
$this->assertSame(Category::OTHER_HASHED, $result->categoryId, "Expected OTHER_HASHED for: $name");
|
|
}
|
|
|
|
#[DataProvider('readableSoftwareNamesProvider')]
|
|
public function test_misc_categorizer_does_not_hash_readable_software_names(string $name): void
|
|
{
|
|
$categorizer = new MiscCategorizer;
|
|
$context = new ReleaseContext(releaseName: $name, groupId: 0);
|
|
$result = $categorizer->categorize($context);
|
|
|
|
$this->assertFalse($result->isSuccessful(), "Readable software name '$name' should not be matched by misc hash heuristics");
|
|
$this->assertSame(Category::OTHER_MISC, $result->categoryId);
|
|
$this->assertSame('no_match', $result->matchedBy);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Tests: MiscPipe lock mechanism
|
|
// ------------------------------------------------------------------
|
|
|
|
#[DataProvider('hashedNamesProvider')]
|
|
public function test_misc_pipe_locks_hashed_releases(string $name, string $expectedMatchedBy): void
|
|
{
|
|
$passable = $this->runPipeline($name);
|
|
|
|
$this->assertTrue($passable->lockedToMisc, "Expected lockedToMisc for: $name");
|
|
$this->assertContains(
|
|
$passable->bestResult->categoryId,
|
|
[Category::OTHER_HASHED, Category::OTHER_MISC],
|
|
"Expected misc category for locked release: $name"
|
|
);
|
|
}
|
|
|
|
#[DataProvider('gibberishNamesProvider')]
|
|
public function test_misc_pipe_locks_gibberish_releases(string $name, string $expectedMatchedBy): void
|
|
{
|
|
$passable = $this->runPipeline($name);
|
|
|
|
$this->assertTrue($passable->lockedToMisc, "Expected lockedToMisc for: $name");
|
|
$this->assertSame(
|
|
Category::OTHER_HASHED,
|
|
$passable->bestResult->categoryId,
|
|
"Expected OTHER_HASHED for locked release: $name"
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Tests: Hashed releases are NOT overridden by group-based categorization
|
|
// ------------------------------------------------------------------
|
|
|
|
#[DataProvider('hashedWithGroupProvider')]
|
|
public function test_hashed_releases_not_overridden_by_group(string $name, string $groupName): void
|
|
{
|
|
$passable = $this->runPipeline($name, $groupName);
|
|
|
|
$this->assertTrue($passable->lockedToMisc, "Expected lockedToMisc for: $name (group: $groupName)");
|
|
$this->assertContains(
|
|
$passable->bestResult->categoryId,
|
|
[Category::OTHER_HASHED, Category::OTHER_MISC],
|
|
"Hashed release '$name' in group '$groupName' should stay in misc, got category: {$passable->bestResult->categoryId}"
|
|
);
|
|
|
|
// Verify it was NOT assigned a content category
|
|
$this->assertNotContains(
|
|
$passable->bestResult->categoryId,
|
|
[
|
|
Category::TV_OTHER, Category::MOVIE_OTHER, Category::XXX_OTHER,
|
|
Category::MUSIC_OTHER, Category::GAME_OTHER, Category::PC_0DAY,
|
|
Category::BOOKS_EBOOK,
|
|
],
|
|
"Hashed release '$name' should NOT be in content category, but got: {$passable->bestResult->categoryId}"
|
|
);
|
|
}
|
|
|
|
#[DataProvider('lowSignalGroupOnlyProvider')]
|
|
public function test_group_only_low_signal_releases_fall_back_to_other_misc(string $name, string $groupName): void
|
|
{
|
|
$passable = $this->runPipeline($name, $groupName);
|
|
|
|
$this->assertTrue($passable->lockedToMisc, "Expected lockedToMisc for low-signal release: $name");
|
|
$this->assertSame(Category::OTHER_MISC, $passable->bestResult->categoryId);
|
|
$this->assertSame('group_only_low_signal', $passable->bestResult->matchedBy);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Tests: Legitimate releases still categorize normally
|
|
// ------------------------------------------------------------------
|
|
|
|
#[DataProvider('legitimateNamesProvider')]
|
|
public function test_legitimate_releases_are_not_locked(string $name, string $groupName): void
|
|
{
|
|
$passable = $this->runPipeline($name, $groupName);
|
|
|
|
$this->assertFalse($passable->lockedToMisc, "Legitimate release '$name' should NOT be locked to misc");
|
|
|
|
// They should NOT end up in OTHER_HASHED
|
|
$this->assertNotSame(
|
|
Category::OTHER_HASHED,
|
|
$passable->bestResult->categoryId,
|
|
"Legitimate release '$name' should NOT be in OTHER_HASHED"
|
|
);
|
|
}
|
|
|
|
public function test_adobe_msix_bundle_reaches_pc_0day_in_full_pipeline(): void
|
|
{
|
|
$passable = $this->runPipeline('Adobe Express Photos.Msixbundle', 'alt.binaries.erotica.divx');
|
|
|
|
$this->assertFalse($passable->lockedToMisc);
|
|
$this->assertSame(Category::PC_0DAY, $passable->bestResult->categoryId);
|
|
$this->assertSame('0day_msix_installer', $passable->bestResult->matchedBy);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Tests: shouldStopProcessing() respects the lock
|
|
// ------------------------------------------------------------------
|
|
|
|
public function test_should_stop_processing_returns_true_when_locked(): void
|
|
{
|
|
$context = new ReleaseContext(releaseName: 'test', groupId: 0);
|
|
$passable = new CategorizationPassable($context);
|
|
|
|
$this->assertFalse($passable->shouldStopProcessing());
|
|
|
|
$passable->lockToMisc();
|
|
|
|
$this->assertTrue($passable->shouldStopProcessing());
|
|
}
|
|
|
|
public function test_locked_passable_prevents_downstream_pipes(): void
|
|
{
|
|
$context = new ReleaseContext(
|
|
releaseName: 'd41d8cd98f00b204e9800998ecf8427e',
|
|
groupId: 0,
|
|
groupName: 'alt.binaries.movies',
|
|
);
|
|
|
|
$passable = new CategorizationPassable($context, debug: true);
|
|
|
|
// Run MiscPipe first
|
|
$miscPipe = new MiscPipe;
|
|
$passable = $miscPipe->handle($passable, fn ($p) => $p);
|
|
|
|
$this->assertTrue($passable->lockedToMisc);
|
|
$this->assertSame(Category::OTHER_HASHED, $passable->bestResult->categoryId);
|
|
|
|
// Now run GroupNamePipe — it should skip because of the lock
|
|
$groupPipe = new GroupNamePipe;
|
|
$passable = $groupPipe->handle($passable, fn ($p) => $p);
|
|
|
|
// Category should still be OTHER_HASHED, not MOVIE_OTHER
|
|
$this->assertSame(Category::OTHER_HASHED, $passable->bestResult->categoryId);
|
|
$this->assertTrue($passable->lockedToMisc);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Tests: CategorizationResult::isSuccessful() changes
|
|
// ------------------------------------------------------------------
|
|
|
|
public function test_other_misc_with_matched_by_is_successful(): void
|
|
{
|
|
$result = new CategorizationResult(
|
|
Category::OTHER_MISC, 0.5, 'obfuscated_pattern'
|
|
);
|
|
|
|
$this->assertTrue($result->isSuccessful());
|
|
}
|
|
|
|
public function test_no_match_sentinel_is_not_successful(): void
|
|
{
|
|
$result = CategorizationResult::noMatch();
|
|
|
|
$this->assertFalse($result->isSuccessful());
|
|
}
|
|
|
|
public function test_other_hashed_is_successful(): void
|
|
{
|
|
$result = new CategorizationResult(
|
|
Category::OTHER_HASHED, 0.95, 'hash_md5'
|
|
);
|
|
|
|
$this->assertTrue($result->isSuccessful());
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Tests: Debug output includes locked_to_misc
|
|
// ------------------------------------------------------------------
|
|
|
|
public function test_debug_output_includes_locked_to_misc(): void
|
|
{
|
|
$passable = $this->runPipeline('d41d8cd98f00b204e9800998ecf8427e');
|
|
$output = $passable->toArray();
|
|
|
|
$this->assertArrayHasKey('debug', $output);
|
|
$this->assertArrayHasKey('locked_to_misc', $output['debug']);
|
|
$this->assertTrue($output['debug']['locked_to_misc']);
|
|
}
|
|
|
|
public function test_debug_output_not_locked_for_legitimate(): void
|
|
{
|
|
$context = new ReleaseContext(
|
|
releaseName: 'Some.Movie.2024.1080p.BluRay.x264-GROUP',
|
|
groupId: 0,
|
|
groupName: '',
|
|
);
|
|
$passable = new CategorizationPassable($context, debug: true);
|
|
|
|
$miscPipe = new MiscPipe;
|
|
$passable = $miscPipe->handle($passable, fn ($p) => $p);
|
|
|
|
$output = $passable->toArray();
|
|
$this->assertArrayHasKey('debug', $output);
|
|
$this->assertArrayHasKey('locked_to_misc', $output['debug']);
|
|
$this->assertFalse($output['debug']['locked_to_misc']);
|
|
}
|
|
}
|