File transfer
This commit is contained in:
155
tests/Addons/Unit/PermissionResolverTest.php
Normal file
155
tests/Addons/Unit/PermissionResolverTest.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Permission Resolver Unit Tests
|
||||
*
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
namespace Tests\Addons\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Illuminate\Support\Collection;
|
||||
use Pterodactyl\Addons\AdvancedAdmin\Models\ServerPermissionOverride;
|
||||
use Pterodactyl\Addons\AdvancedAdmin\Services\Roles\PermissionResolver;
|
||||
|
||||
class PermissionResolverTest extends TestCase
|
||||
{
|
||||
private PermissionResolver $resolver;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->resolver = new PermissionResolver();
|
||||
}
|
||||
|
||||
/** Resolution order: server DENY beats role ALLOW */
|
||||
public function test_server_deny_overrides_role_allow(): void
|
||||
{
|
||||
$override = $this->makeOverride('deny');
|
||||
$role = $this->makeRole('allow');
|
||||
|
||||
$result = $this->resolveWithMocks([$override], [$role], 'file.read');
|
||||
$this->assertFalse($result, 'Server DENY should override role ALLOW');
|
||||
}
|
||||
|
||||
/** Resolution order: server ALLOW beats role DENY */
|
||||
public function test_server_allow_overrides_role_deny(): void
|
||||
{
|
||||
$override = $this->makeOverride('allow');
|
||||
$role = $this->makeRole('deny');
|
||||
|
||||
$result = $this->resolveWithMocks([$override], [$role], 'file.read');
|
||||
$this->assertTrue($result, 'Server ALLOW should override role DENY');
|
||||
}
|
||||
|
||||
/** Role DENY should deny when no server override is set */
|
||||
public function test_role_deny_denies_when_no_server_override(): void
|
||||
{
|
||||
$override = $this->makeOverride('unset');
|
||||
$role = $this->makeRole('deny');
|
||||
|
||||
$result = $this->resolveWithMocks([$override], [$role], 'file.read');
|
||||
$this->assertFalse($result, 'Role DENY should deny when server override is unset');
|
||||
}
|
||||
|
||||
/** Role ALLOW should allow when no server override is set */
|
||||
public function test_role_allow_allows_when_no_server_override(): void
|
||||
{
|
||||
$override = $this->makeOverride('unset');
|
||||
$role = $this->makeRole('allow');
|
||||
|
||||
$result = $this->resolveWithMocks([$override], [$role], 'file.read');
|
||||
$this->assertTrue($result, 'Role ALLOW should allow when server override is unset');
|
||||
}
|
||||
|
||||
/** Default deny when nothing is set */
|
||||
public function test_default_deny_when_nothing_set(): void
|
||||
{
|
||||
$override = $this->makeOverride('unset');
|
||||
$result = $this->resolveWithMocks([$override], [], 'file.read');
|
||||
$this->assertFalse($result, 'Should default to deny when no rules set');
|
||||
}
|
||||
|
||||
/** Higher priority role wins on conflict */
|
||||
public function test_higher_priority_role_wins(): void
|
||||
{
|
||||
$lowRole = $this->makeRole('deny', priority: 10);
|
||||
$highRole = $this->makeRole('allow', priority: 100);
|
||||
$override = $this->makeOverride('unset');
|
||||
|
||||
// High priority role should win (sorted desc by priority)
|
||||
$result = $this->resolveWithMocks([$override], [$lowRole, $highRole], 'file.read');
|
||||
$this->assertTrue($result, 'Higher priority role (allow) should win over lower priority (deny)');
|
||||
}
|
||||
|
||||
/** No privilege escalation — deny still blocks even if other role allows */
|
||||
public function test_explicit_deny_in_any_role_blocks(): void
|
||||
{
|
||||
// If ANY role in resolution order says deny first (by priority), it should deny
|
||||
$denyRole = $this->makeRole('deny', priority: 50);
|
||||
$allowRole = $this->makeRole('allow', priority: 10);
|
||||
$override = $this->makeOverride('unset');
|
||||
|
||||
$result = $this->resolveWithMocks([$override], [$denyRole, $allowRole], 'file.read');
|
||||
$this->assertFalse($result, 'Deny in higher-priority role should block allow in lower role');
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function resolveWithMocks(array $overrides, array $roles, string $permission): bool
|
||||
{
|
||||
// Use a test double of the resolver that accepts mock data
|
||||
$resolver = new class extends PermissionResolver {
|
||||
public array $mockOverrides = [];
|
||||
public array $mockRoles = [];
|
||||
|
||||
public function resolve(int $userId, int $serverId, string $permission): bool
|
||||
{
|
||||
$overridesCollection = collect($this->mockOverrides)->keyBy('permission');
|
||||
$rolesCollection = collect($this->mockRoles)->sortByDesc('priority')->values();
|
||||
[$result] = $this->resolveWithSourcePublic($permission, $overridesCollection, $rolesCollection);
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function resolveWithSourcePublic(string $perm, $overrides, $roles): array
|
||||
{
|
||||
return $this->resolveWithSource($perm, $overrides, $roles);
|
||||
}
|
||||
};
|
||||
|
||||
// This tests the private method via reflection for correctness
|
||||
$ref = new \ReflectionMethod(PermissionResolver::class, 'resolveWithSource');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$overridesCol = collect($overrides)->keyBy('permission');
|
||||
$rolesCol = collect($roles)->sortByDesc('priority')->values();
|
||||
|
||||
[$result] = $ref->invoke($this->resolver, $permission, $overridesCol, $rolesCol);
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function makeOverride(string $value): object
|
||||
{
|
||||
return new class ($value) {
|
||||
public string $permission = 'file.read';
|
||||
public string $value;
|
||||
public function __construct(string $v) { $this->value = $v; }
|
||||
public function isAllow(): bool { return $this->value === 'allow'; }
|
||||
public function isDeny(): bool { return $this->value === 'deny'; }
|
||||
public function isUnset(): bool { return $this->value === 'unset'; }
|
||||
};
|
||||
}
|
||||
|
||||
private function makeRole(string $value, int $priority = 50): object
|
||||
{
|
||||
return new class ($value, $priority) {
|
||||
public int $priority;
|
||||
public string $name = 'Test Role';
|
||||
private string $val;
|
||||
public function __construct(string $v, int $p) { $this->val = $v; $this->priority = $p; }
|
||||
public function getPermission(string $p): ?string { return $this->val === 'unset' ? null : $this->val; }
|
||||
};
|
||||
}
|
||||
}
|
||||
134
tests/Addons/Unit/RevisionStorageTest.php
Normal file
134
tests/Addons/Unit/RevisionStorageTest.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Revision Storage Security Unit Tests
|
||||
*
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user