mini_protection_test.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. <?php
  2. /** Uses isolated, expiring Redis keys. Never flushes Redis or changes business data. */
  3. require dirname(__DIR__) . '/vendor/autoload.php';
  4. \Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
  5. \support\App::loadAllConfig(['route']);
  6. use app\miniapi\auth\Principal;
  7. use app\miniapi\auth\TokenVerifier;
  8. use app\miniapi\exception\ApiException;
  9. use app\miniapi\http\Pagination;
  10. use app\miniapi\middleware\Authenticate;
  11. use app\miniapi\middleware\ProtectTraffic;
  12. use app\miniapi\protection\RedisQuota;
  13. use app\miniapi\protection\RequestProfile;
  14. use app\miniapi\protection\TrafficGuard;
  15. use support\Request;
  16. use support\Response;
  17. use support\Redis;
  18. $checks = 0;
  19. function check(bool $condition, string $message): void
  20. {
  21. global $checks;
  22. $checks++;
  23. if (!$condition) {
  24. throw new RuntimeException($message);
  25. }
  26. }
  27. function settings(array $changes): void
  28. {
  29. $property = new ReflectionProperty(\Webman\Config::class, 'config');
  30. $config = $property->getValue();
  31. foreach ($changes as $key => $value) {
  32. $config[$key] = array_replace($config[$key], $value);
  33. }
  34. $property->setValue(null, $config);
  35. (new ReflectionProperty(\Webman\Config::class, 'flatCache'))->setValue(null, []);
  36. }
  37. function rejects(callable $action, int $code): ApiException
  38. {
  39. try {
  40. $action();
  41. } catch (ApiException $error) {
  42. check($error->getCode() === $code, 'Expected error ' . $code . ', got ' . $error->getCode());
  43. return $error;
  44. }
  45. throw new RuntimeException('Missing error ' . $code);
  46. }
  47. final class TestRequest extends Request
  48. {
  49. public string $remote = '192.0.2.1';
  50. public function getRemoteIp(): string
  51. {
  52. return $this->remote;
  53. }
  54. }
  55. function requestFixture(string $url, string $method = 'GET', string $headers = ''): TestRequest
  56. {
  57. $request = new TestRequest("$method $url HTTP/1.1\r\nHost: localhost\r\n$headers\r\n");
  58. $request->route = new \Webman\Route\Route([$method], '/api/mini/v1/test', fn () => null);
  59. return $request;
  60. }
  61. final class TestVerifier implements TokenVerifier
  62. {
  63. public function verify(string $token): Principal
  64. {
  65. if (!in_array($token, ['token-one', 'token-two'], true)) {
  66. throw new ApiException(40101, 'Invalid', 401);
  67. }
  68. return new Principal('same-account');
  69. }
  70. }
  71. $original = ['mini_protection' => config('mini_protection'), 'mini_api' => config('mini_api')];
  72. $prefix = 'mini-protection-test:' . bin2hex(random_bytes(12)) . ':';
  73. $redis = Redis::connection(config('mini_protection.connection'));
  74. $quota = new RedisQuota();
  75. $workers = [];
  76. try {
  77. settings(['mini_protection' => ['enabled' => true, 'prefix' => $prefix]]);
  78. $bucket = ['key' => $prefix . 'atomic', 'limit' => 2, 'seconds' => 60, 'cost' => 1, 'rule' => 'test'];
  79. check($quota->consume([$bucket])[0] === 0, 'First request allowed');
  80. check($quota->consume([$bucket])[0] === 0, 'Boundary request allowed');
  81. $unused = array_replace($bucket, ['key' => $prefix . 'unused']);
  82. $denied = $quota->consume([$unused, $bucket]);
  83. check($denied[0] === 2 && $denied[1] > 0 && $denied[2] === 1, 'Limit with retry and first notice');
  84. check(!$redis->exists($unused['key']), 'Denied atomic request charges no other bucket');
  85. check($quota->consume([$bucket])[2] === 0, 'Repeated denial does not flood logs');
  86. check($redis->ttl($bucket['key']) > 0, 'Counter expires');
  87. $redis->expire($bucket['key'], 0);
  88. check($quota->consume([$bucket])[0] === 0, 'Expired counter recovers');
  89. $a = requestFixture('/api/mini/v1/enterprises/12/orders?page_size=100&keyword=a');
  90. $b = requestFixture('/api/mini/v1/enterprises/13/orders?page=2&keyword=b');
  91. check(RequestProfile::endpoint($a) === RequestProfile::endpoint($b), 'IDs and query strings cannot evade endpoint quota');
  92. check(RequestProfile::listCost($a) === 100 && RequestProfile::listCost($b) === 20, 'Quota uses requested/default page size');
  93. $a->remote = '::ffff:192.0.2.1';
  94. check(RequestProfile::ip($a) === '192.0.2.1', 'IPv4-mapped address shares IP bucket');
  95. $forged = requestFixture('/api/mini/v1/config', 'GET', "X-Forwarded-For: 203.0.113.7\r\nX-Real-IP: 203.0.113.8\r\n");
  96. check(RequestProfile::ip($forged) === '192.0.2.1', 'Proxy headers cannot spoof client IP');
  97. $unknown = requestFixture('/api/mini/random-attacker-path');
  98. $unknown->route = new \Webman\Route\Route(['GET'], '/api/mini/{path:.*}', fn () => null);
  99. check(RequestProfile::endpoint($unknown) === 'unknown', 'Unknown paths share a bounded bucket');
  100. check(RequestProfile::endpoint(requestFixture('/api/mini/v1/session', 'HEAD')) === RequestProfile::endpoint(requestFixture('/api/mini/v1/session')), 'HEAD cannot evade GET bucket');
  101. rejects(fn () => new Pagination(['page' => 101, 'page_size' => 100]), 42201);
  102. check((new Pagination(['page' => 100, 'page_size' => 100]))->offset() === 9900, 'Deep-page boundary allows last page');
  103. rejects(fn () => RequestProfile::listCost(requestFixture('/api/mini/v1/packages?page_size=101')), 42201);
  104. $limits = array_fill_keys(array_keys($original['mini_protection']['limits']), [1000, 60]);
  105. $limits['ip_requests'] = [2, 60];
  106. settings(['mini_protection' => ['limits' => $limits]]);
  107. $middleware = new ProtectTraffic();
  108. $handled = 0;
  109. $handler = function () use (&$handled) {
  110. $handled++;
  111. return new Response(200, [], 'ok');
  112. };
  113. check($middleware->process($forged, $handler)->getStatusCode() === 200, 'Normal request allowed');
  114. $middleware->process($forged, $handler);
  115. $response = $middleware->process($forged, $handler);
  116. check($response->getStatusCode() === 429 && $handled === 2, 'IP limit blocks before business handler');
  117. check((int) $response->getHeader('Retry-After') > 0, 'Retry-After header is present');
  118. check(json_decode($response->rawBody(), true)['code'] === 42902, 'Uniform API error envelope');
  119. check($middleware->process(requestFixture('/api/mini/v1/health'), $handler)->getStatusCode() === 200, 'Health remains available');
  120. check($middleware->process(requestFixture('/core/water/overview'), $handler)->getStatusCode() === 200, 'Admin routes unaffected');
  121. $limits['ip_requests'] = [1000, 60];
  122. $limits['account_endpoint'] = [2, 60];
  123. settings(['mini_protection' => ['limits' => $limits], 'mini_api' => ['token_verifier' => TestVerifier::class]]);
  124. $auth = new Authenticate();
  125. foreach (['token-one', 'token-two'] as $token) {
  126. $auth->process(requestFixture('/api/mini/v1/me', 'GET', "Authorization: Bearer $token\r\n"), $handler);
  127. }
  128. rejects(fn () => $auth->process(requestFixture('/api/mini/v1/me', 'GET', "Authorization: Bearer token-two\r\n"), $handler), 42902);
  129. rejects(fn () => $auth->process(requestFixture('/api/mini/v1/me', 'GET', "Authorization: Bearer forged\r\n"), $handler), 40101);
  130. $guard = new TrafficGuard();
  131. $guard->check('account', 'another-account', 'GET /api/mini/v1/me', 0, 'test-other');
  132. check(true, 'Different account has independent quota');
  133. $limits['account_list_rows'] = [100, 3600];
  134. settings(['mini_protection' => ['limits' => $limits]]);
  135. $guard->check('account', 'reader', 'GET /api/mini/v1/packages', 60, 'test-list');
  136. rejects(fn () => $guard->check('account', 'reader', 'GET /api/mini/v1/agents/{id}/orders', 60, 'test-list'), 42902);
  137. $limits['ip_list_rows'] = [100, 3600];
  138. settings(['mini_protection' => ['limits' => $limits]]);
  139. $listRequest = requestFixture('/api/mini/v1/packages?page_size=60');
  140. $listRequest->remote = '192.0.2.99';
  141. check($middleware->process($listRequest, $handler)->getStatusCode() === 200, 'Public list charges IP budget');
  142. $response = $middleware->process($listRequest, $handler);
  143. check($response->getStatusCode() === 429 && (int) $response->getHeader('Retry-After') > 60, 'Public list quota blocks repeated page reads');
  144. $limits['account_requests'] = [2, 60];
  145. $limits['account_endpoint'] = [1000, 60];
  146. settings(['mini_protection' => ['limits' => $limits]]);
  147. $guard->check('account', 'total-reader', 'GET /one', 0, 'test-total');
  148. $guard->check('account', 'total-reader', 'GET /two', 0, 'test-total');
  149. rejects(fn () => $guard->check('account', 'total-reader', 'GET /three', 0, 'test-total'), 42902);
  150. settings(['mini_protection' => ['connection' => 'not-configured']]);
  151. rejects(fn () => $guard->check('account', 'outage', 'GET /api/mini/v1/me', 0, 'test-outage'), 50303);
  152. check($middleware->process(requestFixture('/api/mini/v1/config'), $handler)->getStatusCode() === 503, 'Redis outage does not silently disable protection');
  153. check($middleware->process(requestFixture('/api/mini/v1/health'), $handler)->getStatusCode() === 200, 'Health remains available during Redis outage');
  154. settings(['mini_protection' => ['enabled' => false]]);
  155. $guard->check('account', 'outage', 'GET /api/mini/v1/me', 0, 'test-disabled');
  156. check(true, 'Explicit rollback switch skips Redis');
  157. settings(['mini_protection' => ['enabled' => true, 'connection' => $original['mini_protection']['connection']]]);
  158. for ($i = 0; $i < 8; $i++) {
  159. $process = proc_open([PHP_BINARY, __DIR__ . '/support/mini_quota_worker.php', $prefix . 'concurrent'], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
  160. if (!is_resource($process)) {
  161. throw new RuntimeException('Cannot start concurrent worker');
  162. }
  163. $workers[] = [$process, $pipes];
  164. }
  165. $accepted = 0;
  166. foreach ($workers as [$process, $pipes]) {
  167. $output = stream_get_contents($pipes[1]);
  168. $error = stream_get_contents($pipes[2]);
  169. fclose($pipes[1]);
  170. fclose($pipes[2]);
  171. check(proc_close($process) === 0 && ctype_digit($output), 'Concurrent worker completes: ' . $error);
  172. $accepted += (int) $output;
  173. }
  174. $workers = [];
  175. check($accepted === 35 && (int) $redis->get($prefix . 'concurrent') === 35, '160 concurrent attempts admit exactly 35 requests');
  176. } finally {
  177. foreach ($workers as [$process, $pipes]) {
  178. if (is_resource($process)) {
  179. proc_terminate($process);
  180. }
  181. }
  182. // Scan/delete only this randomly named test namespace; never FLUSHDB/FLUSHALL.
  183. $cursor = null;
  184. $client = $redis->client();
  185. do {
  186. $keys = $client->scan($cursor, $prefix . '*', 100);
  187. if ($keys) {
  188. $client->del($keys);
  189. }
  190. } while ($cursor !== 0);
  191. settings($original);
  192. }
  193. echo "PASS: $checks protection checks; isolated Redis test keys removed.\n";