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

289 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\Opsdash\Tests\Service;
use OCA\Opsdash\Service\CalendarAccessService;
use OCA\Opsdash\Service\CalendarColorService;
use OCA\Opsdash\Service\CalendarParsingService;
use OCA\Opsdash\Service\DashboardDefaultsService;
use OCA\Opsdash\Service\OverviewAggregationService;
use OCA\Opsdash\Service\OverviewBalanceService;
use OCA\Opsdash\Service\OverviewChartsBuilder;
use OCA\Opsdash\Service\OverviewCorePayloadComposer;
use OCA\Opsdash\Service\OverviewDataService;
use OCA\Opsdash\Service\OverviewEventsCollector;
use OCA\Opsdash\Service\OverviewHistoryService;
use OCA\Opsdash\Service\OverviewIncludeResolver;
use OCA\Opsdash\Service\OverviewLoadCacheService;
use OCA\Opsdash\Service\OverviewLoadContextService;
use OCA\Opsdash\Service\OverviewLoadResponseComposer;
use OCA\Opsdash\Service\OverviewLoadService;
use OCA\Opsdash\Service\OverviewSelectionService;
use OCA\Opsdash\Service\OverviewStatsDeltaService;
use OCA\Opsdash\Service\OverviewStatsKpiService;
use OCA\Opsdash\Service\OverviewStatsHistoryService;
use OCA\Opsdash\Service\OverviewStatsService;
use OCA\Opsdash\Service\OverviewStatsTrendService;
use OCA\Opsdash\Service\PersistSanitizer;
use OCA\Opsdash\Service\UserConfigService;
use OCP\Calendar\IManager;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IConfig;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
final class OverviewLoadServiceTest extends TestCase {
private string|false $prevEnabled;
protected function setUp(): void {
parent::setUp();
$this->prevEnabled = getenv('OPSDASH_CACHE_ENABLED');
putenv('OPSDASH_CACHE_ENABLED=0');
}
protected function tearDown(): void {
if ($this->prevEnabled === false) {
putenv('OPSDASH_CACHE_ENABLED');
} else {
putenv('OPSDASH_CACHE_ENABLED=' . $this->prevEnabled);
}
parent::tearDown();
}
public function testLoadReturnsCorePayloadWhenDataNotRequested(): void {
$config = new LoadConfigStub();
$calendarAccess = new LoadCalendarAccess([
new LoadCalendarStub('cal-1', 'Personal', '#abc'),
new LoadCalendarStub('cal-2', 'Work', '#112233'),
]);
$persistSanitizer = new PersistSanitizer();
$userConfig = new UserConfigService($config, $persistSanitizer, $this->createMock(LoggerInterface::class));
$includeResolver = new OverviewIncludeResolver();
$cacheService = new OverviewLoadCacheService(
new LoadCacheFactory(),
$config,
$userConfig,
$includeResolver,
$this->createMock(LoggerInterface::class),
);
$dataService = $this->buildDataService($calendarAccess, $config);
$contextService = new OverviewLoadContextService(
$calendarAccess,
new CalendarColorService(),
$persistSanitizer,
$userConfig,
new OverviewSelectionService(),
$includeResolver,
new DashboardDefaultsService(),
$config,
);
$responseComposer = new OverviewLoadResponseComposer(new OverviewCorePayloadComposer());
$loadService = new OverviewLoadService(
$this->createMock(LoggerInterface::class),
$dataService,
$cacheService,
$contextService,
$responseComposer,
);
$payload = $loadService->load('opsdash', 'admin', [
'range' => 'week',
'offset' => 0,
'provided' => false,
'cals' => null,
'debug' => false,
'forceReset' => false,
'include' => ['core'],
]);
$this->assertTrue($payload['ok']);
$this->assertSame('week', $payload['meta']['range']);
$this->assertSame(['cal-1', 'cal-2'], $payload['selected']);
$this->assertSame('UTC', $payload['userSettings']['timezone']);
$this->assertArrayHasKey('calendars', $payload);
$this->assertArrayHasKey('colors', $payload);
$this->assertArrayNotHasKey('byDay', $payload);
}
public function testLoadReturnsDebugDisabledWhenRequestedWithoutDebugMode(): void {
$config = new LoadConfigStub();
$calendarAccess = new LoadCalendarAccess([
new LoadCalendarStub('cal-1', 'Personal', '#abc'),
]);
$persistSanitizer = new PersistSanitizer();
$userConfig = new UserConfigService($config, $persistSanitizer, $this->createMock(LoggerInterface::class));
$includeResolver = new OverviewIncludeResolver();
$cacheService = new OverviewLoadCacheService(
new LoadCacheFactory(),
$config,
$userConfig,
$includeResolver,
$this->createMock(LoggerInterface::class),
);
$dataService = $this->buildDataService($calendarAccess, $config);
$contextService = new OverviewLoadContextService(
$calendarAccess,
new CalendarColorService(),
$persistSanitizer,
$userConfig,
new OverviewSelectionService(),
$includeResolver,
new DashboardDefaultsService(),
$config,
);
$responseComposer = new OverviewLoadResponseComposer(new OverviewCorePayloadComposer());
$loadService = new OverviewLoadService(
$this->createMock(LoggerInterface::class),
$dataService,
$cacheService,
$contextService,
$responseComposer,
);
$payload = $loadService->load('opsdash', 'admin', [
'range' => 'week',
'offset' => 0,
'provided' => false,
'cals' => null,
'debug' => true,
'forceReset' => false,
'include' => ['debug'],
]);
$this->assertSame(['enabled' => false], $payload['debug']);
}
private function buildDataService(CalendarAccessService $calendarAccess, IConfig $config): OverviewDataService {
$calendarParsing = new CalendarParsingService();
$manager = new class implements IManager {};
$collector = new OverviewEventsCollector($manager, $calendarParsing, $this->createMock(LoggerInterface::class));
$history = new OverviewHistoryService($calendarAccess, $collector);
$aggregation = new OverviewAggregationService();
$historyService = new OverviewStatsHistoryService(
new OverviewStatsDeltaService($calendarAccess, $history, $collector, $aggregation),
new OverviewStatsTrendService($history),
);
$statsService = new OverviewStatsService(
new OverviewStatsKpiService(),
$historyService,
new OverviewBalanceService(),
);
return new OverviewDataService(
$collector,
$aggregation,
new OverviewChartsBuilder(),
$statsService,
$calendarAccess,
);
}
}
final class LoadCalendarAccess extends CalendarAccessService {
/** @var array<int, object> */
private array $calendars;
public function __construct(array $calendars) {
parent::__construct(
new class implements IManager {},
new class implements IConfig {
public function getAppValue(string $appName, string $key, string $default = ''): string { return $default; }
public function getUserValue(string $userId, string $appName, string $key, string $default = ''): string { return $default; }
public function setUserValue(string $userId, string $appName, string $key, string $value): void {}
},
new class implements LoggerInterface {
public function emergency($message, array $context = []): void {}
public function alert($message, array $context = []): void {}
public function critical($message, array $context = []): void {}
public function error($message, array $context = []): void {}
public function warning($message, array $context = []): void {}
public function notice($message, array $context = []): void {}
public function info($message, array $context = []): void {}
public function debug($message, array $context = []): void {}
public function log($level, $message, array $context = []): void {}
},
);
$this->calendars = $calendars;
}
public function getCalendarsFor(string $uid): array {
return $this->calendars;
}
public function resolveUserTimezone(string $uid): \DateTimeZone {
return new \DateTimeZone('UTC');
}
public function resolveUserWeekStart(string $uid): int {
return 1;
}
public function rangeBounds(string $range, int $offset, ?\DateTimeZone $tz = null, ?int $weekStart = null): array {
return [
new \DateTimeImmutable('2024-01-01 00:00:00', new \DateTimeZone('UTC')),
new \DateTimeImmutable('2024-01-07 23:59:59', new \DateTimeZone('UTC')),
];
}
}
final class LoadCalendarStub {
public function __construct(
private string $uri,
private string $displayName,
private string $displayColor,
) {}
public function getUri(): string { return $this->uri; }
public function getDisplayName(): string { return $this->displayName; }
public function getDisplayColor(): string { return $this->displayColor; }
}
final class LoadConfigStub implements IConfig {
public function getAppValue(string $appName, string $key, string $default = ''): string { return $default; }
public function getUserValue(string $userId, string $appName, string $key, string $default = ''): string {
if ($key === 'selected_cals') {
return '__UNSET__';
}
return $default;
}
public function setUserValue(string $userId, string $appName, string $key, string $value): void {}
public function getSystemValue(string $key, $default = null): mixed {
if ($key === 'loglevel') {
return 2;
}
return $default;
}
}
final class LoadCacheFactory implements ICacheFactory {
private ICache $cache;
public function __construct() {
$this->cache = new class implements ICache {
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); }
};
}
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;
}
}