174 lines
4.7 KiB
PHP
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);
|
|
});
|