From 18bb6bffa760fb25eb98204b992982af560a10d0 Mon Sep 17 00:00:00 2001 From: Roman Date: Wed, 24 Apr 2024 10:11:54 +0200 Subject: [PATCH] Redis + RateLimiting Unit Test --- Core/Driver/Redis/RedisConnection.class.php | 4 + Core/Objects/Context.class.php | 6 +- Core/Objects/DatabaseEntity/Session.class.php | 2 +- .../src/views/settings/input-selection.js | 21 ++- .../src/views/settings/settings.js | 2 +- test/RateLimiting.test.php | 173 ++++++++++++++++++ test/Request.test.php | 4 + 7 files changed, 202 insertions(+), 10 deletions(-) create mode 100644 test/RateLimiting.test.php diff --git a/Core/Driver/Redis/RedisConnection.class.php b/Core/Driver/Redis/RedisConnection.class.php index c07ad71..1373f2d 100644 --- a/Core/Driver/Redis/RedisConnection.class.php +++ b/Core/Driver/Redis/RedisConnection.class.php @@ -17,6 +17,10 @@ class RedisConnection { $this->link = new \Redis(); } + public function getLogger(): Logger { + return $this->logger; + } + public function connect(ConnectionData $connectionData): bool { try { $this->link->connect($connectionData->getHost(), $connectionData->getPort()); diff --git a/Core/Objects/Context.class.php b/Core/Objects/Context.class.php index 27f2843..5e03ba2 100644 --- a/Core/Objects/Context.class.php +++ b/Core/Objects/Context.class.php @@ -21,14 +21,14 @@ class Context { private static Context $instance; private ?SQL $sql; - private ?Session $session; + protected ?Session $session; private ?User $user; private Configuration $configuration; private Language $language; public ?Router $router; - private ?RedisConnection $redis; + protected ?RedisConnection $redis; - private function __construct() { + protected function __construct() { $this->sql = null; $this->session = null; diff --git a/Core/Objects/DatabaseEntity/Session.class.php b/Core/Objects/DatabaseEntity/Session.class.php index e553361..627ee13 100644 --- a/Core/Objects/DatabaseEntity/Session.class.php +++ b/Core/Objects/DatabaseEntity/Session.class.php @@ -20,7 +20,7 @@ class Session extends DatabaseEntity { private User $user; private DateTime $expires; #[MaxLength(45)] private string $ipAddress; - #[MaxLength(36)] private string $uuid; + #[MaxLength(36)] protected string $uuid; #[DefaultValue(true)] private bool $active; #[MaxLength(64)] private ?string $os; #[MaxLength(64)] private ?string $browser; diff --git a/react/admin-panel/src/views/settings/input-selection.js b/react/admin-panel/src/views/settings/input-selection.js index 3073fd5..bba252b 100644 --- a/react/admin-panel/src/views/settings/input-selection.js +++ b/react/admin-panel/src/views/settings/input-selection.js @@ -8,17 +8,28 @@ export default function SettingsSelection(props) { const {key_name, value, options, onChangeValue, disabled, ...other} = props; const {translate: L} = useContext(LocaleContext); + let optionElements = []; + if (Array.isArray(options)) { + optionElements = options.map(option => ); + } else { + optionElements = Object.entries(options).map(([value, label]) => ); + } + return {L("settings." + key_name)} diff --git a/react/admin-panel/src/views/settings/settings.js b/react/admin-panel/src/views/settings/settings.js index c610995..baabe92 100644 --- a/react/admin-panel/src/views/settings/settings.js +++ b/react/admin-panel/src/views/settings/settings.js @@ -284,7 +284,7 @@ export default function SettingsView(props) { ]; } else if (selectedTab === "captcha") { return [ - renderSelection("captcha_provider", ["none", "recaptcha", "hcaptcha"]), + renderSelection("captcha_provider", {"none": L("settings.none"), "recaptcha": "Google reCaptcha", "hcaptcha": "hCaptcha"}), renderTextInput("captcha_site_key", settings.captcha_provider === "none"), renderPasswordInput("captcha_secret_key", settings.captcha_provider === "none"), ]; diff --git a/test/RateLimiting.test.php b/test/RateLimiting.test.php new file mode 100644 index 0000000..c8acc4c --- /dev/null +++ b/test/RateLimiting.test.php @@ -0,0 +1,173 @@ +assertEquals(1, $secondsRule->getCount()); + $this->assertEquals(1, $secondsRule->getWindow()); + + $secondsRule2 = new \Core\Objects\RateLimitRule(5, 120, \Core\Objects\RateLimitRule::SECOND); + $this->assertEquals(5, $secondsRule2->getCount()); + $this->assertEquals(120, $secondsRule2->getWindow()); + + $minuteRule = new \Core\Objects\RateLimitRule(10, 5, \Core\Objects\RateLimitRule::MINUTE); + $this->assertEquals(10, $minuteRule->getCount()); + $this->assertEquals(5 * 60, $minuteRule->getWindow()); + + $hourRule = new \Core\Objects\RateLimitRule(15, 4, \Core\Objects\RateLimitRule::HOUR); + $this->assertEquals(15, $hourRule->getCount()); + $this->assertEquals(4 * 60 * 60, $hourRule->getWindow()); + + // should be interpreted as 30 seconds, ignoring invalid unit + $invalidUnitRule = new \Core\Objects\RateLimitRule(20, 30, 10); + $this->assertEquals(20, $invalidUnitRule->getCount()); + $this->assertEquals(30, $invalidUnitRule->getWindow()); + } + + public function testRateLimiting() { + + $testContext = new TestContext(); + $redis = $testContext->getRedis(); + $this->assertTrue($redis->isConnected()); + + $_SERVER["REMOTE_ADDR"] = "0.0.0.0"; + $method = "test/method"; + $sessionUUID = uuidv4(); + $windowSize = 10; + + // should pass + $noRateLimiting = new \Core\Objects\RateLimiting(); + for ($i = 0; $i < 100; $i++) { + $this->assertTrue($noRateLimiting->check($testContext, $method)); + } + + $anonymousRateLimiting = new \Core\Objects\RateLimiting( + new \Core\Objects\RateLimitRule($windowSize, 5, \Core\Objects\RateLimitRule::SECOND) + ); + + for ($i = 0; $i < $windowSize; $i++) { + $this->assertTrue($anonymousRateLimiting->check($testContext, $method)); + $this->assertCount($i + 1, json_decode($redis->hGet($method, $_SERVER["REMOTE_ADDR"]))); + } + $this->assertFalse($anonymousRateLimiting->check($testContext, $method)); + self::$CURRENT_TIME += 4; + $this->assertFalse($anonymousRateLimiting->check($testContext, $method)); + self::$CURRENT_TIME += 2; + $this->assertTrue($anonymousRateLimiting->check($testContext, $method)); + $this->assertCount($windowSize, json_decode($redis->hGet($method, $_SERVER["REMOTE_ADDR"]))); + + $testContext->setSession($sessionUUID); + for ($i = 0; $i < 100; $i++) { + $this->assertTrue($anonymousRateLimiting->check($testContext, $method)); + } + + $sessionBasedRateLimiting = new \Core\Objects\RateLimiting( + null, + new \Core\Objects\RateLimitRule($windowSize, 5, \Core\Objects\RateLimitRule::SECOND) + ); + + for ($i = 0; $i < $windowSize; $i++) { + $this->assertTrue($sessionBasedRateLimiting->check($testContext, $method)); + $this->assertCount($i + 1, json_decode($redis->hGet($method, $sessionUUID))); + } + + $this->assertFalse($sessionBasedRateLimiting->check($testContext, $method)); + self::$CURRENT_TIME += 10; + $this->assertTrue($sessionBasedRateLimiting->check($testContext, $method)); + $testContext->destroySession(); + for ($i = 0; $i < 100; $i++) { + $this->assertTrue($sessionBasedRateLimiting->check($testContext, $method)); + } + } +} + +class TestSession extends \Core\Objects\DatabaseEntity\Session { + public function __construct(Context $context, string $uuid) { + parent::__construct($context, new \Core\Objects\DatabaseEntity\User()); + $this->uuid = $uuid; + } +} + +class TestContext extends Context { + + public function __construct() { + parent::__construct(); + $this->redis = new TestRedisConnection(); + } + + public function getRedis(): ?\Core\Driver\Redis\RedisConnection { + return $this->redis; + } + + public function setSession(string $sessionUUID): void { + $this->session = new TestSession($this, $sessionUUID); + } + + public function destroySession(): void { + $this->session = null; + } +} + +class TestRedisConnection extends \Core\Driver\Redis\RedisConnection { + + private array $redisData; + + public function __construct() { + parent::__construct(null); + $this->getLogger()->unitTestMode(); + $this->redisData = []; + } + + public function connect(\Core\Objects\ConnectionData $connectionData): bool { + return true; + } + + public function isConnected(): bool { + return true; + } + + public function hSet(string $hashKey, mixed $key, string $value): bool { + if (!isset($this->redisData[$hashKey])) { + $this->redisData[$hashKey] = []; + } + + $this->redisData[$hashKey][$key] = $value; + return true; + } + + public function hGet(string $hashKey, string $key): ?string { + if (!isset($this->redisData[$hashKey])) { + return ""; + } + + return $this->redisData[$hashKey][$key] ?? ""; + } +} \ No newline at end of file diff --git a/test/Request.test.php b/test/Request.test.php index 417f950..43f3c80 100644 --- a/test/Request.test.php +++ b/test/Request.test.php @@ -163,6 +163,10 @@ abstract class TestRequest extends Request { public static function getEndpoint(string $prefix = ""): ?string { return "test"; } + + public static function getDescription(): string { + return "test description"; + } } class RequestAllMethods extends TestRequest {