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).
227 lines
6.4 KiB
PHP
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;
|
|
}
|
|
}
|