opsdash-app/opsdash/tests/php/Service/LoadRateLimiterTest.php
blade34242 ea2237e3fd security: rate limit /loadData to 5 req/10 s per user via ICacheFactory
Adds LoadRateLimiter with a two-layer check: 2-second minimum interval
between requests, and a burst cap of 5 per 10-second window. Uses the
distributed cache (Redis/APCu) so the limit applies across workers.
Fails open when cache is unavailable so a cold cache never blocks users.
Returns 429 Too Many Requests when throttled.

Also updates ICacheFactory stub and FakeCacheFactory implementations to
match the full interface (isAvailable, createDistributed, createInMemory).
2026-05-06 13:59:12 +07:00

95 lines
3.4 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\Opsdash\Tests\Service;
use OCA\Opsdash\Service\LoadRateLimiter;
use OCP\ICacheFactory;
use OCP\ICache;
use PHPUnit\Framework\TestCase;
final class LoadRateLimiterTest extends TestCase {
private function makeCache(bool $available, array &$store = []): ICacheFactory {
$cache = $this->createMock(ICache::class);
$cache->method('get')->willReturnCallback(function (string $key) use (&$store) {
return $store[$key] ?? null;
});
$cache->method('set')->willReturnCallback(function (string $key, $value) use (&$store): bool {
$store[$key] = $value;
return true;
});
$factory = $this->createMock(ICacheFactory::class);
$factory->method('isAvailable')->willReturn($available);
$factory->method('createDistributed')->willReturn($cache);
return $factory;
}
public function testAllowsFirstRequest(): void {
$factory = $this->makeCache(true);
$limiter = new LoadRateLimiter($factory);
$this->assertTrue($limiter->allow('user1'));
}
public function testBlocksImmediateRepeat(): void {
$store = [];
$factory = $this->makeCache(true, $store);
$limiter = new LoadRateLimiter($factory);
$this->assertTrue($limiter->allow('user1'));
$this->assertFalse($limiter->allow('user1'), 'Second immediate request should be throttled');
}
public function testUsersAreIsolated(): void {
$store = [];
$factory = $this->makeCache(true, $store);
$limiter = new LoadRateLimiter($factory);
$this->assertTrue($limiter->allow('user1'));
$this->assertTrue($limiter->allow('user2'), 'Different user should not be throttled');
}
public function testFailsOpenWhenCacheUnavailable(): void {
$factory = $this->makeCache(false);
$limiter = new LoadRateLimiter($factory);
$this->assertTrue($limiter->allow('user1'));
$this->assertTrue($limiter->allow('user1'), 'Should always allow when cache unavailable');
}
public function testBurstLimitBlocks(): void {
$store = [];
$factory = $this->createMock(ICacheFactory::class);
$factory->method('isAvailable')->willReturn(true);
$cache = $this->createMock(ICache::class);
$factory->method('createDistributed')->willReturn($cache);
// Simulate: min-interval never blocks (last seen is long ago), but burst count is maxed.
$cache->method('get')->willReturnCallback(function (string $key) use (&$store) {
if (str_starts_with($key, 'last_')) {
return time() - 10; // old enough to pass min-interval
}
if (str_starts_with($key, 'burst_')) {
return 5; // at the limit
}
return null;
});
$cache->method('set')->willReturn(true);
$limiter = new LoadRateLimiter($factory);
$this->assertFalse($limiter->allow('user1'), 'Should block when burst counter is at max');
}
public function testFailsOpenOnCacheException(): void {
$factory = $this->createMock(ICacheFactory::class);
$factory->method('isAvailable')->willReturn(true);
$factory->method('createDistributed')->willThrowException(new \RuntimeException('cache down'));
$limiter = new LoadRateLimiter($factory);
$this->assertTrue($limiter->allow('user1'), 'Should fail open on cache error');
}
}