opsdash-app/opsdash/tests/php/Service/OverviewLoadCacheServiceTest.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

227 lines
6.4 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\Opsdash\Tests\Service;
use OCA\Opsdash\Service\OverviewIncludeResolver;
use OCA\Opsdash\Service\OverviewLoadCacheService;
use OCA\Opsdash\Service\PersistSanitizer;
use OCA\Opsdash\Service\UserConfigService;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IConfig;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
final class OverviewLoadCacheServiceTest extends TestCase {
private string|false $prevEnabled;
private string|false $prevTtl;
protected function setUp(): void {
parent::setUp();
$this->prevEnabled = getenv('OPSDASH_CACHE_ENABLED');
$this->prevTtl = getenv('OPSDASH_CACHE_TTL');
}
protected function tearDown(): void {
if ($this->prevEnabled === false) {
putenv('OPSDASH_CACHE_ENABLED');
} else {
putenv('OPSDASH_CACHE_ENABLED=' . $this->prevEnabled);
}
if ($this->prevTtl === false) {
putenv('OPSDASH_CACHE_TTL');
} else {
putenv('OPSDASH_CACHE_TTL=' . $this->prevTtl);
}
parent::tearDown();
}
public function testIsCacheEnabledUsesEnv(): void {
putenv('OPSDASH_CACHE_ENABLED=0');
$service = $this->buildService('1');
$this->assertFalse($service->isCacheEnabled('opsdash'));
}
public function testCacheTtlReadsEnvAndClamps(): void {
putenv('OPSDASH_CACHE_TTL=-5');
$service = $this->buildService('60');
$this->assertSame(0, $service->cacheTtl('opsdash'));
putenv('OPSDASH_CACHE_TTL=15');
$this->assertSame(15, $service->cacheTtl('opsdash'));
}
public function testCoreCacheTtlIsMinOfCoreAndConfig(): void {
putenv('OPSDASH_CACHE_TTL=60');
$service = $this->buildService('60');
$this->assertSame(30, $service->coreCacheTtl('opsdash'));
}
public function testWriteAndReadCoreCache(): void {
putenv('OPSDASH_CACHE_TTL=10');
$service = $this->buildService('10');
$includes = ['calendars' => true];
$payload = ['calendars' => [['id' => 'cal-1']]];
$storedAt = $service->writeCoreCache('opsdash', 'admin', $includes, 'UTC', 'en', 1, $payload);
$this->assertIsInt($storedAt);
$cached = $service->readCoreCache('opsdash', 'admin', $includes, 'UTC', 'en', 1);
$this->assertNotNull($cached);
$this->assertSame($payload, $cached['payload']);
$this->assertSame($storedAt, $cached['storedAt']);
}
public function testCoreCacheVersionBumpInvalidatesPreviousCoreEntry(): void {
putenv('OPSDASH_CACHE_TTL=10');
$service = $this->buildService('10');
$includes = ['calendars' => true];
$payload = ['calendars' => [['id' => 'cal-1']]];
$service->writeCoreCache('opsdash', 'admin', $includes, 'UTC', 'en', 1, $payload);
$this->assertNotNull($service->readCoreCache('opsdash', 'admin', $includes, 'UTC', 'en', 1));
$service->bumpUserCoreCacheVersion('opsdash', 'admin');
$this->assertNull($service->readCoreCache('opsdash', 'admin', $includes, 'UTC', 'en', 1));
$service->writeCoreCache('opsdash', 'admin', $includes, 'UTC', 'en', 1, $payload);
$this->assertNotNull($service->readCoreCache('opsdash', 'admin', $includes, 'UTC', 'en', 1));
}
public function testWriteAndReadDataCache(): void {
putenv('OPSDASH_CACHE_TTL=10');
$service = $this->buildService('10');
$payload = ['byDay' => []];
$meta = ['truncated' => false];
$storedAt = $service->writeDataCache(
'opsdash',
'admin',
'week',
0,
['cal-1'],
['cal-1' => 0],
['cal-1' => 12],
['cal-1' => 48],
['totalHours' => 48],
['enabled' => false],
['enabled' => true],
['stats' => true],
'UTC',
'en',
1,
$payload,
$meta,
);
$this->assertIsInt($storedAt);
$cached = $service->readDataCache(
'opsdash',
'admin',
'week',
0,
['cal-1'],
['cal-1' => 0],
['cal-1' => 12],
['cal-1' => 48],
['totalHours' => 48],
['enabled' => false],
['enabled' => true],
['stats' => true],
'UTC',
'en',
1,
);
$this->assertNotNull($cached);
$this->assertSame($payload, $cached['payload']);
$this->assertSame($meta, $cached['meta']);
}
private function buildService(string $cacheEnabledValue): OverviewLoadCacheService {
$cache = new FakeCache();
$factory = new FakeCacheFactory($cache);
$config = new CacheConfigStub($cacheEnabledValue);
$logger = $this->createMock(LoggerInterface::class);
$userConfig = new UserConfigService($config, new PersistSanitizer(), $logger);
return new OverviewLoadCacheService(
$factory,
$config,
$userConfig,
new OverviewIncludeResolver(),
$logger,
);
}
}
final class FakeCache implements ICache {
/** @var array<string,mixed> */
private array $store = [];
public function get(string $key) {
return $this->store[$key] ?? null;
}
public function set(string $key, $value, int $ttl = 0): bool {
$this->store[$key] = $value;
return true;
}
public function hasKey(string $key): bool {
return array_key_exists($key, $this->store);
}
}
final class FakeCacheFactory implements ICacheFactory {
public function __construct(private ICache $cache) {}
public function isAvailable(): bool { return true; }
public function isLocalCacheAvailable(): bool { return true; }
public function createLocal(string $prefix = ''): ICache {
return $this->cache;
}
public function createDistributed(string $prefix = ''): ICache {
return $this->cache;
}
public function createInMemory(int $capacity = 512): ICache {
return $this->cache;
}
}
final class CacheConfigStub implements IConfig {
/** @var array<string,string> */
private array $userValues = [];
public function __construct(private string $cacheEnabled) {}
public function getAppValue(string $appName, string $key, string $default = ''): string {
if ($key === 'cache_enabled') {
return $this->cacheEnabled;
}
return $default;
}
public function getUserValue(string $userId, string $appName, string $key, string $default = ''): string {
$idx = $userId . ':' . $appName . ':' . $key;
return $this->userValues[$idx] ?? $default;
}
public function setUserValue(string $userId, string $appName, string $key, string $value): void {
$idx = $userId . ':' . $appName . ':' . $key;
$this->userValues[$idx] = $value;
}
public function getSystemValue(string $key, $default = null): mixed {
if ($key === 'loglevel') {
return 2;
}
return $default;
}
}