* @license MIT */ namespace Tests\Addons\Unit; use PHPUnit\Framework\TestCase; use Pterodactyl\Addons\AdvancedAdmin\Services\Revisions\RevisionStorageService; use Pterodactyl\Addons\AdvancedAdmin\Services\Revisions\ExclusionMatcher; class RevisionStorageTest extends TestCase { private RevisionStorageService $storage; protected function setUp(): void { parent::setUp(); $this->storage = new RevisionStorageService(); } // ── Path Traversal Prevention ───────────────────────────────────────────── public function test_rejects_absolute_path(): void { $this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class); $this->storage->guardFilePath('/etc/passwd'); } public function test_rejects_dotdot_traversal(): void { $this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class); $this->storage->guardFilePath('../../../etc/passwd'); } public function test_rejects_encoded_traversal(): void { $this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class); $this->storage->guardFilePath('./../../secret'); } public function test_accepts_valid_relative_path(): void { $result = $this->storage->guardFilePath('config/server.properties'); $this->assertEquals('config/server.properties', $result); } // ── Binary Detection ────────────────────────────────────────────────────── public function test_detects_binary_content(): void { $binary = "MZ\x90\x00\x03\x00\x00\x00"; // PE header $this->assertTrue($this->storage->isBinary($binary)); } public function test_accepts_text_content(): void { $text = "# Config file\nserver-name=My Server\nmax-players=20\n"; $this->assertFalse($this->storage->isBinary($text)); } // ── Content Hashing ─────────────────────────────────────────────────────── public function test_content_hash_is_consistent(): void { $content = "hello world"; $hash1 = $this->storage->contentHash($content); $hash2 = $this->storage->contentHash($content); $this->assertEquals($hash1, $hash2); } public function test_different_content_produces_different_hash(): void { $h1 = $this->storage->contentHash("content A"); $h2 = $this->storage->contentHash("content B"); $this->assertNotEquals($h1, $h2); } // ── Path Hash is Server-Scoped ───────────────────────────────────────────── public function test_path_hash_is_server_scoped(): void { $hash1 = $this->storage->pathHash(1, 'config.yml'); $hash2 = $this->storage->pathHash(2, 'config.yml'); $this->assertNotEquals($hash1, $hash2, 'Same file on different servers must have different path hashes'); } } /** * Exclusion Matcher Tests */ class ExclusionMatcherTest extends TestCase { private function matcher(array $patterns = []): ExclusionMatcher { // Override config for testing config(['advanced-admin.revisions.excluded_patterns' => $patterns ?: [ '.env', '*.key', '*.pem', '*.p12', 'secrets.yml', '*.sql', '*.bak', ]]); return new ExclusionMatcher(); } public function test_excludes_dotenv(): void { $this->assertTrue($this->matcher()->isExcluded('.env')); } public function test_excludes_key_files(): void { $this->assertTrue($this->matcher()->isExcluded('private.key')); $this->assertTrue($this->matcher()->isExcluded('server.pem')); } public function test_excludes_sql_dumps(): void { $this->assertTrue($this->matcher()->isExcluded('backup.sql')); } public function test_allows_normal_config(): void { $this->assertFalse($this->matcher()->isExcluded('server.properties')); $this->assertFalse($this->matcher()->isExcluded('config/spigot.yml')); } public function test_excludes_sensitive_keyword_in_name(): void { $this->assertTrue($this->matcher()->isExcluded('my-secret-key.txt')); $this->assertTrue($this->matcher()->isExcluded('password-store.txt')); } }