load(); \support\App::loadAllConfig(['route']); use app\miniapi\auth\Principal; use app\miniapi\auth\TokenVerifier; use app\miniapi\exception\ApiException; use app\miniapi\http\Pagination; use app\miniapi\middleware\Authenticate; use app\miniapi\middleware\ProtectTraffic; use app\miniapi\protection\RedisQuota; use app\miniapi\protection\RequestProfile; use app\miniapi\protection\TrafficGuard; use support\Request; use support\Response; use support\Redis; $checks = 0; function check(bool $condition, string $message): void { global $checks; $checks++; if (!$condition) { throw new RuntimeException($message); } } function settings(array $changes): void { $property = new ReflectionProperty(\Webman\Config::class, 'config'); $config = $property->getValue(); foreach ($changes as $key => $value) { $config[$key] = array_replace($config[$key], $value); } $property->setValue(null, $config); (new ReflectionProperty(\Webman\Config::class, 'flatCache'))->setValue(null, []); } function rejects(callable $action, int $code): ApiException { try { $action(); } catch (ApiException $error) { check($error->getCode() === $code, 'Expected error ' . $code . ', got ' . $error->getCode()); return $error; } throw new RuntimeException('Missing error ' . $code); } final class TestRequest extends Request { public string $remote = '192.0.2.1'; public function getRemoteIp(): string { return $this->remote; } } function requestFixture(string $url, string $method = 'GET', string $headers = ''): TestRequest { $request = new TestRequest("$method $url HTTP/1.1\r\nHost: localhost\r\n$headers\r\n"); $request->route = new \Webman\Route\Route([$method], '/api/mini/v1/test', fn () => null); return $request; } final class TestVerifier implements TokenVerifier { public function verify(string $token): Principal { if (!in_array($token, ['token-one', 'token-two'], true)) { throw new ApiException(40101, 'Invalid', 401); } return new Principal('same-account'); } } $original = ['mini_protection' => config('mini_protection'), 'mini_api' => config('mini_api')]; $prefix = 'mini-protection-test:' . bin2hex(random_bytes(12)) . ':'; $redis = Redis::connection(config('mini_protection.connection')); $quota = new RedisQuota(); $workers = []; try { settings(['mini_protection' => ['enabled' => true, 'prefix' => $prefix]]); $bucket = ['key' => $prefix . 'atomic', 'limit' => 2, 'seconds' => 60, 'cost' => 1, 'rule' => 'test']; check($quota->consume([$bucket])[0] === 0, 'First request allowed'); check($quota->consume([$bucket])[0] === 0, 'Boundary request allowed'); $unused = array_replace($bucket, ['key' => $prefix . 'unused']); $denied = $quota->consume([$unused, $bucket]); check($denied[0] === 2 && $denied[1] > 0 && $denied[2] === 1, 'Limit with retry and first notice'); check(!$redis->exists($unused['key']), 'Denied atomic request charges no other bucket'); check($quota->consume([$bucket])[2] === 0, 'Repeated denial does not flood logs'); check($redis->ttl($bucket['key']) > 0, 'Counter expires'); $redis->expire($bucket['key'], 0); check($quota->consume([$bucket])[0] === 0, 'Expired counter recovers'); $a = requestFixture('/api/mini/v1/enterprises/12/orders?page_size=100&keyword=a'); $b = requestFixture('/api/mini/v1/enterprises/13/orders?page=2&keyword=b'); check(RequestProfile::endpoint($a) === RequestProfile::endpoint($b), 'IDs and query strings cannot evade endpoint quota'); check(RequestProfile::listCost($a) === 100 && RequestProfile::listCost($b) === 20, 'Quota uses requested/default page size'); $a->remote = '::ffff:192.0.2.1'; check(RequestProfile::ip($a) === '192.0.2.1', 'IPv4-mapped address shares IP bucket'); $forged = requestFixture('/api/mini/v1/config', 'GET', "X-Forwarded-For: 203.0.113.7\r\nX-Real-IP: 203.0.113.8\r\n"); check(RequestProfile::ip($forged) === '192.0.2.1', 'Proxy headers cannot spoof client IP'); $unknown = requestFixture('/api/mini/random-attacker-path'); $unknown->route = new \Webman\Route\Route(['GET'], '/api/mini/{path:.*}', fn () => null); check(RequestProfile::endpoint($unknown) === 'unknown', 'Unknown paths share a bounded bucket'); check(RequestProfile::endpoint(requestFixture('/api/mini/v1/session', 'HEAD')) === RequestProfile::endpoint(requestFixture('/api/mini/v1/session')), 'HEAD cannot evade GET bucket'); rejects(fn () => new Pagination(['page' => 101, 'page_size' => 100]), 42201); check((new Pagination(['page' => 100, 'page_size' => 100]))->offset() === 9900, 'Deep-page boundary allows last page'); rejects(fn () => RequestProfile::listCost(requestFixture('/api/mini/v1/packages?page_size=101')), 42201); $limits = array_fill_keys(array_keys($original['mini_protection']['limits']), [1000, 60]); $limits['ip_requests'] = [2, 60]; settings(['mini_protection' => ['limits' => $limits]]); $middleware = new ProtectTraffic(); $handled = 0; $handler = function () use (&$handled) { $handled++; return new Response(200, [], 'ok'); }; check($middleware->process($forged, $handler)->getStatusCode() === 200, 'Normal request allowed'); $middleware->process($forged, $handler); $response = $middleware->process($forged, $handler); check($response->getStatusCode() === 429 && $handled === 2, 'IP limit blocks before business handler'); check((int) $response->getHeader('Retry-After') > 0, 'Retry-After header is present'); check(json_decode($response->rawBody(), true)['code'] === 42902, 'Uniform API error envelope'); check($middleware->process(requestFixture('/api/mini/v1/health'), $handler)->getStatusCode() === 200, 'Health remains available'); check($middleware->process(requestFixture('/core/water/overview'), $handler)->getStatusCode() === 200, 'Admin routes unaffected'); $limits['ip_requests'] = [1000, 60]; $limits['account_endpoint'] = [2, 60]; settings(['mini_protection' => ['limits' => $limits], 'mini_api' => ['token_verifier' => TestVerifier::class]]); $auth = new Authenticate(); foreach (['token-one', 'token-two'] as $token) { $auth->process(requestFixture('/api/mini/v1/me', 'GET', "Authorization: Bearer $token\r\n"), $handler); } rejects(fn () => $auth->process(requestFixture('/api/mini/v1/me', 'GET', "Authorization: Bearer token-two\r\n"), $handler), 42902); rejects(fn () => $auth->process(requestFixture('/api/mini/v1/me', 'GET', "Authorization: Bearer forged\r\n"), $handler), 40101); $guard = new TrafficGuard(); $guard->check('account', 'another-account', 'GET /api/mini/v1/me', 0, 'test-other'); check(true, 'Different account has independent quota'); $limits['account_list_rows'] = [100, 3600]; settings(['mini_protection' => ['limits' => $limits]]); $guard->check('account', 'reader', 'GET /api/mini/v1/packages', 60, 'test-list'); rejects(fn () => $guard->check('account', 'reader', 'GET /api/mini/v1/agents/{id}/orders', 60, 'test-list'), 42902); $limits['ip_list_rows'] = [100, 3600]; settings(['mini_protection' => ['limits' => $limits]]); $listRequest = requestFixture('/api/mini/v1/packages?page_size=60'); $listRequest->remote = '192.0.2.99'; check($middleware->process($listRequest, $handler)->getStatusCode() === 200, 'Public list charges IP budget'); $response = $middleware->process($listRequest, $handler); check($response->getStatusCode() === 429 && (int) $response->getHeader('Retry-After') > 60, 'Public list quota blocks repeated page reads'); $limits['account_requests'] = [2, 60]; $limits['account_endpoint'] = [1000, 60]; settings(['mini_protection' => ['limits' => $limits]]); $guard->check('account', 'total-reader', 'GET /one', 0, 'test-total'); $guard->check('account', 'total-reader', 'GET /two', 0, 'test-total'); rejects(fn () => $guard->check('account', 'total-reader', 'GET /three', 0, 'test-total'), 42902); settings(['mini_protection' => ['connection' => 'not-configured']]); rejects(fn () => $guard->check('account', 'outage', 'GET /api/mini/v1/me', 0, 'test-outage'), 50303); check($middleware->process(requestFixture('/api/mini/v1/config'), $handler)->getStatusCode() === 503, 'Redis outage does not silently disable protection'); check($middleware->process(requestFixture('/api/mini/v1/health'), $handler)->getStatusCode() === 200, 'Health remains available during Redis outage'); settings(['mini_protection' => ['enabled' => false]]); $guard->check('account', 'outage', 'GET /api/mini/v1/me', 0, 'test-disabled'); check(true, 'Explicit rollback switch skips Redis'); settings(['mini_protection' => ['enabled' => true, 'connection' => $original['mini_protection']['connection']]]); for ($i = 0; $i < 8; $i++) { $process = proc_open([PHP_BINARY, __DIR__ . '/support/mini_quota_worker.php', $prefix . 'concurrent'], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); if (!is_resource($process)) { throw new RuntimeException('Cannot start concurrent worker'); } $workers[] = [$process, $pipes]; } $accepted = 0; foreach ($workers as [$process, $pipes]) { $output = stream_get_contents($pipes[1]); $error = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); check(proc_close($process) === 0 && ctype_digit($output), 'Concurrent worker completes: ' . $error); $accepted += (int) $output; } $workers = []; check($accepted === 35 && (int) $redis->get($prefix . 'concurrent') === 35, '160 concurrent attempts admit exactly 35 requests'); } finally { foreach ($workers as [$process, $pipes]) { if (is_resource($process)) { proc_terminate($process); } } // Scan/delete only this randomly named test namespace; never FLUSHDB/FLUSHALL. $cursor = null; $client = $redis->client(); do { $keys = $client->scan($cursor, $prefix . '*', 100); if ($keys) { $client->del($keys); } } while ($cursor !== 0); settings($original); } echo "PASS: $checks protection checks; isolated Redis test keys removed.\n";