Files
api/services/nginx/app/tests/Unit/Selfserve/ShellyGlobalRateLimitBehaviorTest.php
T

174 lines
4.7 KiB
PHP

<?php
app_require('classes/shelly.php');
use classes\shelly;
class GlobalShellyRateLimitClock
{
public float $now = 0.0;
}
class GlobalShellyRateLimitRedisClientFake
{
public function __construct(private readonly GlobalShellyRateLimitRedisFake $owner) {}
public function set(string $key, string $value, mixed ...$args): bool|string
{
return $this->owner->clientSet($key, $value, $args);
}
public function pttl(string $key): int
{
return $this->owner->clientPttl($key);
}
}
class GlobalShellyRateLimitRedisFake
{
/** @var array<string,string> */
private array $store = [];
/** @var array<string,float> */
private array $expiresAt = [];
private GlobalShellyRateLimitRedisClientFake $client;
public function __construct(private readonly GlobalShellyRateLimitClock $clock)
{
$this->client = new GlobalShellyRateLimitRedisClientFake($this);
}
public function get_client(): GlobalShellyRateLimitRedisClientFake
{
return $this->client;
}
public function clientSet(string $key, string $value, array $args): bool|string
{
$this->purgeExpired($key);
$useNx = false;
$ttlMs = null;
$count = count($args);
for ($i = 0; $i < $count; $i++) {
$token = strtoupper((string)$args[$i]);
if ($token === 'NX') {
$useNx = true;
continue;
}
if ($token === 'PX' && isset($args[$i + 1])) {
$ttlMs = max(1, (int)$args[$i + 1]);
$i++;
}
}
if ($useNx && array_key_exists($key, $this->store)) {
return false;
}
$this->store[$key] = $value;
if ($ttlMs === null) {
unset($this->expiresAt[$key]);
} else {
$this->expiresAt[$key] = $this->clock->now + ($ttlMs / 1000);
}
return 'OK';
}
public function clientPttl(string $key): int
{
$this->purgeExpired($key);
if (!array_key_exists($key, $this->store)) {
return -2;
}
if (!isset($this->expiresAt[$key])) {
return -1;
}
$remainingMs = (int)ceil(($this->expiresAt[$key] - $this->clock->now) * 1000);
return $remainingMs > 0 ? $remainingMs : 0;
}
private function purgeExpired(string $key): void
{
if (!isset($this->expiresAt[$key])) {
return;
}
if ($this->clock->now < $this->expiresAt[$key]) {
return;
}
unset($this->expiresAt[$key], $this->store[$key]);
}
}
class GlobalShellyRateLimitHarness extends shelly
{
/** @var array<int,int> */
public array $sleepCalls = [];
public function __construct(
private readonly GlobalShellyRateLimitClock $clock,
private readonly ?GlobalShellyRateLimitRedisFake $redis = null
) {
}
public function waitForShellyRateLimitWindowForTest(): void
{
$invoke = \Closure::bind(function (): void {
$this->waitForShellyRateLimitWindow();
}, $this, shelly::class);
$invoke();
}
protected function redisFacade(): mixed
{
return $this->redis;
}
protected function nowTimestamp(): float
{
return $this->clock->now;
}
protected function sleepMicroseconds(int $microseconds): void
{
if ($microseconds <= 0) {
return;
}
$this->sleepCalls[] = $microseconds;
$this->clock->now += ($microseconds / 1000000);
}
}
it('enforces the 2 second Shelly gate across separate request contexts', function (): void {
$clock = new GlobalShellyRateLimitClock();
$redis = new GlobalShellyRateLimitRedisFake($clock);
$requestA = new GlobalShellyRateLimitHarness($clock, $redis);
$requestB = new GlobalShellyRateLimitHarness($clock, $redis);
$requestA->waitForShellyRateLimitWindowForTest();
$timeBeforeRequestB = $clock->now;
$requestB->waitForShellyRateLimitWindowForTest();
expect(array_sum($requestA->sleepCalls))->toBe(0);
expect(array_sum($requestB->sleepCalls))->toBeGreaterThanOrEqual(2000000);
expect($clock->now - $timeBeforeRequestB)->toBeGreaterThanOrEqual(2.0);
});
it('does not delay when the Shelly gate is already expired', function (): void {
$clock = new GlobalShellyRateLimitClock();
$redis = new GlobalShellyRateLimitRedisFake($clock);
$firstRequest = new GlobalShellyRateLimitHarness($clock, $redis);
$firstRequest->waitForShellyRateLimitWindowForTest();
$clock->now += 2.1;
$secondRequest = new GlobalShellyRateLimitHarness($clock, $redis);
$secondRequest->waitForShellyRateLimitWindowForTest();
expect(array_sum($secondRequest->sleepCalls))->toBe(0);
});