| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206 |
- <?php
- /** Uses isolated, expiring Redis keys. Never flushes Redis or changes business data. */
- require dirname(__DIR__) . '/vendor/autoload.php';
- \Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->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";
|