Resolve recommended-profile Critical and High findings, retain narrow analyzer exceptions, and update the edge-broker WebSocket dependency to a non-vulnerable release.
355 lines
11 KiB
PHP
355 lines
11 KiB
PHP
<?php
|
|
|
|
app_require('routes/moduleWeatherAPIRoute.php');
|
|
|
|
use routes\moduleWeatherAPIRoute;
|
|
|
|
class DepartmentWeatherRedisClientFake
|
|
{
|
|
/** @var array<string,array<string,float>> */
|
|
public array $zsets = [];
|
|
|
|
public function reset(): void
|
|
{
|
|
$this->zsets = [];
|
|
}
|
|
|
|
public function zadd(string $key, int|float|string $score, string $member): void
|
|
{
|
|
if (!isset($this->zsets[$key])) {
|
|
$this->zsets[$key] = [];
|
|
}
|
|
$this->zsets[$key][$member] = (float)$score;
|
|
}
|
|
|
|
public function zrevrange(string $key, int $start, int $stop): array
|
|
{
|
|
$members = $this->zsets[$key] ?? [];
|
|
if ($members === []) {
|
|
return [];
|
|
}
|
|
|
|
arsort($members, SORT_NUMERIC);
|
|
$member_ids = array_keys($members);
|
|
if ($stop < 0) {
|
|
$stop = count($member_ids) + $stop;
|
|
}
|
|
$length = max(0, $stop - $start + 1);
|
|
if ($length === 0) {
|
|
return [];
|
|
}
|
|
|
|
return array_values(array_slice($member_ids, $start, $length));
|
|
}
|
|
|
|
public function zremrangebyscore(string $key, int|float|string $min, int|float|string $max): int
|
|
{
|
|
$members = $this->zsets[$key] ?? [];
|
|
if ($members === []) {
|
|
return 0;
|
|
}
|
|
|
|
$min_score = $this->toScoreBoundary($min, true);
|
|
$max_score = $this->toScoreBoundary($max, false);
|
|
$removed = 0;
|
|
|
|
foreach ($members as $member => $score) {
|
|
if ($score >= $min_score && $score <= $max_score) {
|
|
unset($members[$member]);
|
|
$removed++;
|
|
}
|
|
}
|
|
|
|
if ($members === []) {
|
|
unset($this->zsets[$key]);
|
|
} else {
|
|
$this->zsets[$key] = $members;
|
|
}
|
|
|
|
return $removed;
|
|
}
|
|
|
|
public function zrem(string $key, string $member): int
|
|
{
|
|
if (!isset($this->zsets[$key][$member])) {
|
|
return 0;
|
|
}
|
|
|
|
unset($this->zsets[$key][$member]);
|
|
if ($this->zsets[$key] === []) {
|
|
unset($this->zsets[$key]);
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
private function toScoreBoundary(int|float|string $value, bool $is_min): float
|
|
{
|
|
if (is_string($value)) {
|
|
$trimmed = strtolower(trim($value));
|
|
if ($trimmed === '-inf') {
|
|
return -INF;
|
|
}
|
|
if ($trimmed === '+inf' || $trimmed === 'inf') {
|
|
return INF;
|
|
}
|
|
}
|
|
|
|
$numeric = (float)$value;
|
|
if (!is_finite($numeric)) {
|
|
return $is_min ? -INF : INF;
|
|
}
|
|
|
|
return $numeric;
|
|
}
|
|
}
|
|
|
|
class DepartmentWeatherRedisFake
|
|
{
|
|
/** @var array<string,string> */
|
|
public array $store = [];
|
|
/** @var array<int,array{key:string,ttl:int,value:string}> */
|
|
public array $setExCalls = [];
|
|
/** @var array<int,array{key:string,value:string,ttl:int}> */
|
|
public array $lockCalls = [];
|
|
public bool $lockResult = true;
|
|
|
|
private DepartmentWeatherRedisClientFake $client;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->client = new DepartmentWeatherRedisClientFake();
|
|
}
|
|
|
|
public function reset(): void
|
|
{
|
|
$this->store = [];
|
|
$this->setExCalls = [];
|
|
$this->lockCalls = [];
|
|
$this->lockResult = true;
|
|
$this->client->reset();
|
|
}
|
|
|
|
public function get(string $key): ?string
|
|
{
|
|
return $this->store[$key] ?? null;
|
|
}
|
|
|
|
public function setEx(string $key, string $value, int $ttl): self
|
|
{
|
|
$this->store[$key] = $value;
|
|
$this->setExCalls[] = [
|
|
'key' => $key,
|
|
'ttl' => $ttl,
|
|
'value' => $value,
|
|
];
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function set_if_absent_with_expiration(string $key, string $value, int $ttl): bool
|
|
{
|
|
$this->lockCalls[] = [
|
|
'key' => $key,
|
|
'value' => $value,
|
|
'ttl' => $ttl,
|
|
];
|
|
|
|
if (!$this->lockResult) {
|
|
return false;
|
|
}
|
|
if (array_key_exists($key, $this->store)) {
|
|
return false;
|
|
}
|
|
|
|
$this->store[$key] = $value;
|
|
|
|
return true;
|
|
}
|
|
|
|
public function get_client(): DepartmentWeatherRedisClientFake
|
|
{
|
|
return $this->client;
|
|
}
|
|
}
|
|
|
|
function department_weather_runtime_redis(): DepartmentWeatherRedisFake
|
|
{
|
|
if (!defined('redis')) {
|
|
define('redis', new DepartmentWeatherRedisFake());
|
|
}
|
|
|
|
/** @var DepartmentWeatherRedisFake $redis */
|
|
$redis = redis;
|
|
|
|
return $redis;
|
|
}
|
|
|
|
function department_weather_runtime_invoke_private(moduleWeatherAPIRoute $route, string $method, array $args = []): mixed
|
|
{
|
|
$reflection = new ReflectionClass($route);
|
|
$target = $reflection->getMethod($method);
|
|
|
|
return $target->invokeArgs($route, $args);
|
|
}
|
|
|
|
beforeEach(function (): void {
|
|
$_SERVER['REQUEST_URI'] = '/departments/weather';
|
|
$this->oldTtl = getenv('DEPARTMENTS_WEATHER_CACHE_TTL');
|
|
$this->oldStaleTtl = getenv('DEPARTMENTS_WEATHER_STALE_TTL');
|
|
$this->oldHotTtl = getenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL');
|
|
department_weather_runtime_redis()->reset();
|
|
});
|
|
|
|
afterEach(function (): void {
|
|
if ($this->oldTtl === false) {
|
|
putenv('DEPARTMENTS_WEATHER_CACHE_TTL');
|
|
} else {
|
|
putenv('DEPARTMENTS_WEATHER_CACHE_TTL=' . $this->oldTtl);
|
|
}
|
|
|
|
if ($this->oldStaleTtl === false) {
|
|
putenv('DEPARTMENTS_WEATHER_STALE_TTL');
|
|
} else {
|
|
putenv('DEPARTMENTS_WEATHER_STALE_TTL=' . $this->oldStaleTtl);
|
|
}
|
|
|
|
if ($this->oldHotTtl === false) {
|
|
putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL');
|
|
} else {
|
|
putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=' . $this->oldHotTtl);
|
|
}
|
|
});
|
|
|
|
it('builds department weather preload targets in cli without a request uri', function (): void {
|
|
unset($_SERVER['REQUEST_URI']);
|
|
|
|
$targets = moduleWeatherAPIRoute::getDepartmentWeatherHotPreloadTargets(5, 900);
|
|
|
|
expect($targets)->toBe([]);
|
|
});
|
|
|
|
it('serves fresh cached payloads without invoking the resolver', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
putenv('DEPARTMENTS_WEATHER_CACHE_TTL=60');
|
|
putenv('DEPARTMENTS_WEATHER_STALE_TTL=300');
|
|
putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=900');
|
|
|
|
$range = [
|
|
'start' => new DateTime('2026-03-24 00:00:00'),
|
|
'endExclusive' => new DateTime('2026-03-25 00:00:00'),
|
|
];
|
|
$cacheKey = department_weather_runtime_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3, 5], $range]);
|
|
department_weather_runtime_redis()->store[$cacheKey] = (string)json_encode([
|
|
'generated_at' => time() - 5,
|
|
'timeline' => [['source' => 'cache']],
|
|
]);
|
|
|
|
$calls = 0;
|
|
$result = department_weather_runtime_invoke_private($route, 'withCachedDepartmentWeatherTimeline', [
|
|
[1, 3, 5],
|
|
$range,
|
|
static function () use (&$calls): array {
|
|
$calls++;
|
|
return [['source' => 'resolver']];
|
|
},
|
|
]);
|
|
|
|
expect($result)->toBe([['source' => 'cache']]);
|
|
expect($calls)->toBe(0);
|
|
});
|
|
|
|
it('serves stale cached payloads and enqueues a refresh signal', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
putenv('DEPARTMENTS_WEATHER_CACHE_TTL=60');
|
|
putenv('DEPARTMENTS_WEATHER_STALE_TTL=300');
|
|
putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=900');
|
|
|
|
$range = [
|
|
'start' => new DateTime('2026-03-24 00:00:00'),
|
|
'endExclusive' => new DateTime('2026-03-25 00:00:00'),
|
|
];
|
|
$cacheKey = department_weather_runtime_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3, 5], $range]);
|
|
department_weather_runtime_redis()->store[$cacheKey] = (string)json_encode([
|
|
'generated_at' => time() - 120,
|
|
'timeline' => [['source' => 'stale-cache']],
|
|
]);
|
|
|
|
$calls = 0;
|
|
$result = department_weather_runtime_invoke_private($route, 'withCachedDepartmentWeatherTimeline', [
|
|
[1, 3, 5],
|
|
$range,
|
|
static function () use (&$calls): array {
|
|
$calls++;
|
|
return [['source' => 'resolver']];
|
|
},
|
|
]);
|
|
|
|
$targets = moduleWeatherAPIRoute::getDepartmentWeatherHotPreloadTargets(5, 900);
|
|
|
|
expect($result)->toBe([['source' => 'stale-cache']]);
|
|
expect($calls)->toBe(0);
|
|
expect(department_weather_runtime_redis()->lockCalls)->toHaveCount(1);
|
|
expect($targets)->not->toBeEmpty();
|
|
});
|
|
|
|
it('recomputes and rewrites cache payloads when stale window is exceeded', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
putenv('DEPARTMENTS_WEATHER_CACHE_TTL=60');
|
|
putenv('DEPARTMENTS_WEATHER_STALE_TTL=300');
|
|
putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=900');
|
|
|
|
$range = [
|
|
'start' => new DateTime('2026-03-24 00:00:00'),
|
|
'endExclusive' => new DateTime('2026-03-25 00:00:00'),
|
|
];
|
|
$cacheKey = department_weather_runtime_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3, 5], $range]);
|
|
department_weather_runtime_redis()->store[$cacheKey] = (string)json_encode([
|
|
'generated_at' => time() - 400,
|
|
'timeline' => [['source' => 'expired-cache']],
|
|
]);
|
|
|
|
$calls = 0;
|
|
$result = department_weather_runtime_invoke_private($route, 'withCachedDepartmentWeatherTimeline', [
|
|
[1, 3, 5],
|
|
$range,
|
|
static function () use (&$calls): array {
|
|
$calls++;
|
|
return [['source' => 'resolver']];
|
|
},
|
|
]);
|
|
|
|
$cacheWriteFound = false;
|
|
foreach (department_weather_runtime_redis()->setExCalls as $call) {
|
|
if ($call['key'] === $cacheKey && $call['ttl'] === 300) {
|
|
$cacheWriteFound = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
expect($result)->toBe([['source' => 'resolver']]);
|
|
expect($calls)->toBe(1);
|
|
expect($cacheWriteFound)->toBeTrue();
|
|
});
|
|
|
|
it('records hot keys order-insensitively and applies preload target caps', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=900');
|
|
|
|
$range = [
|
|
'start' => new DateTime('2026-03-24 00:00:00'),
|
|
'endExclusive' => new DateTime('2026-03-25 00:00:00'),
|
|
];
|
|
|
|
department_weather_runtime_invoke_private($route, 'recordDepartmentWeatherHotRequest', [[5, 1, 3], $range]);
|
|
department_weather_runtime_invoke_private($route, 'recordDepartmentWeatherHotRequest', [[1, 3, 5], $range]);
|
|
department_weather_runtime_invoke_private($route, 'recordDepartmentWeatherHotRequest', [[8], $range]);
|
|
department_weather_runtime_invoke_private($route, 'recordDepartmentWeatherHotRequest', [[9], $range]);
|
|
|
|
$hotSet = department_weather_runtime_redis()->get_client()->zsets['departments_weather:hot_activity:v1'] ?? [];
|
|
$targets = moduleWeatherAPIRoute::getDepartmentWeatherHotPreloadTargets(2, 900);
|
|
|
|
expect($hotSet)->toHaveCount(3);
|
|
expect($targets)->toHaveCount(2);
|
|
});
|