mini_api_framework_test.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. <?php
  2. /** Framework-only checks: no user, inventory, payment or database writes. */
  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\controller\SystemController;
  9. use app\miniapi\exception\ApiException;
  10. use app\miniapi\exception\Handler;
  11. use app\miniapi\http\ApiResponse;
  12. use app\miniapi\http\Input;
  13. use app\miniapi\http\Pagination;
  14. use app\miniapi\middleware\ApiBoundary;
  15. use app\miniapi\middleware\Authenticate;
  16. use support\Request;
  17. $checks = 0;
  18. function check(bool $ok, string $label): void
  19. {
  20. global $checks;
  21. $checks++;
  22. if (!$ok) {
  23. throw new RuntimeException($label);
  24. }
  25. }
  26. function rejects(callable $fn, int $code): void
  27. {
  28. try {
  29. $fn();
  30. } catch (ApiException $e) {
  31. check($e->getCode() === $code, 'expected error ' . $code);
  32. return;
  33. }throw new RuntimeException('Missing rejection ' . $code);
  34. }
  35. function req(string $method = 'GET', string $path = '/api/mini/v1/health', string $body = '', array $headers = []): Request
  36. {
  37. $head = '';
  38. foreach ($headers as $k => $v) {
  39. $head .= $k . ': ' . $v . "\r\n";
  40. }
  41. return new Request($method . ' ' . $path . " HTTP/1.1\r\nHost: localhost\r\n" . $head . 'Content-Length: ' . strlen($body) . "\r\n\r\n" . $body);
  42. }
  43. function settings(array $changes): void
  44. {
  45. $property = new ReflectionProperty(\Webman\Config::class, 'config');
  46. $config = $property->getValue();
  47. $config['mini_api'] = array_replace($config['mini_api'], $changes);
  48. $property->setValue(null, $config);
  49. (new ReflectionProperty(\Webman\Config::class, 'flatCache'))->setValue(null, []);
  50. }
  51. final class FixtureVerifier implements TokenVerifier
  52. {
  53. public function verify(string $token): Principal
  54. {
  55. if ($token !== 'fixture-only') {
  56. throw new ApiException(40101, '登录状态已失效', 401);
  57. }
  58. return new Principal('verified-user');
  59. }
  60. }
  61. $original = config('mini_api');
  62. $controller = new SystemController();
  63. $boundary = new ApiBoundary();
  64. $auth = new Authenticate();
  65. $errors = new Handler(new \Psr\Log\NullLogger(), true);
  66. try {
  67. $rateFile = tempnam(sys_get_temp_dir(), 'mini-rate-');
  68. settings([
  69. 'login_throttle_file' => $rateFile,
  70. 'login_ip_per_minute' => 2,
  71. 'login_global_per_minute' => 3,
  72. ]);
  73. try {
  74. $throttle = new \app\miniapi\auth\LoginThrottle();
  75. $throttle->consume('127.0.0.1');
  76. $throttle->consume('127.0.0.1');
  77. rejects(fn () => $throttle->consume('127.0.0.1'), 42901);
  78. $throttle->consume('127.0.0.2');
  79. rejects(fn () => $throttle->consume('127.0.0.3'), 42901);
  80. file_put_contents($rateFile, json_encode([
  81. 'window' => intdiv(time(), 60) - 1,
  82. 'total' => 999,
  83. 'clients' => [],
  84. ]));
  85. $throttle->consume('127.0.0.1');
  86. check(json_decode(file_get_contents($rateFile), true)['total'] === 1, 'expired login throttle window resets');
  87. } finally {
  88. unlink($rateFile);
  89. settings($original);
  90. }
  91. $request = req();
  92. $response = $boundary->process($request, fn ($r) => $controller->health($r));
  93. $body = json_decode($response->rawBody(), true);
  94. check($response->getStatusCode() === 200 && $body['code'] === 0 && $body['data']['version'] === 'v1', 'health contract');
  95. check($body['data']['capabilities']['business'] && !$body['data']['capabilities']['wechat_payment'], 'accurate business availability');
  96. check(preg_match('/^[a-f0-9]{32}$/', $body['request_id']) === 1 && $response->getHeader('X-Request-Id') === $body['request_id'], 'request correlation');
  97. $other = req('GET', '/api/mini/v1/health', '', ['X-Request-Id' => 'attacker-controlled']);
  98. $otherBody = json_decode($boundary->process($other, fn ($r) => $controller->health($r))->rawBody(), true);
  99. check($otherBody['request_id'] !== $body['request_id'] && $otherBody['request_id'] !== 'attacker-controlled', 'fresh server-generated request ID');
  100. $legacy = req('GET', '/core/water/overview');
  101. $unchanged = new \support\Response(202, [], 'existing');
  102. check($boundary->process($legacy, fn () => $unchanged) === $unchanged && !isset($legacy->miniRequestId), 'non-mini middleware unchanged');
  103. check(!ApiResponse::owns(req('GET', '/api/minimum')) && ApiResponse::owns(req('GET', '/api/mini')), 'prefix boundary');
  104. check($boundary->process(req('GET', '/api/mini/v1/health', '', ['Origin' => 'https://untrusted.invalid']), fn () => throw new RuntimeException('must not run'))->getStatusCode() === 403, 'unknown origin rejected before endpoint');
  105. settings(['allowed_origins' => ['https://app.example.test']]);
  106. $preflight = $boundary->process(req('OPTIONS', '/api/mini/v1/session', '', ['Origin' => 'https://app.example.test']), fn () => throw new RuntimeException('must not authenticate OPTIONS'));
  107. check($preflight->getStatusCode() === 204 && $preflight->getHeader('Access-Control-Allow-Origin') === 'https://app.example.test', 'configured preflight');
  108. check($preflight->getHeader('Access-Control-Allow-Credentials') === null, 'no cookie credentials enabled');
  109. settings(['max_body_bytes' => 4]);
  110. check($boundary->process(req('POST', '/api/mini/v1/test', '12345'), fn () => throw new RuntimeException('too large'))->getStatusCode() === 413, 'body limit before handler');
  111. rejects(fn () => Input::json(req('POST', '/api/mini/v1/test', '12345', ['Content-Type' => 'application/json'])), 41301);
  112. settings($original);
  113. check(Input::json(req('POST', '/api/mini/v1/test?value=untrusted', '{"value":1}', ['Content-Type' => 'application/json; charset=utf-8'])) === ['value' => 1], 'JSON command does not merge query');
  114. check(Input::json(req('POST', '/api/mini/v1/test', '{}', ['Content-Type' => 'application/json'])) === [], 'empty object allowed');
  115. foreach ([
  116. '',
  117. '{',
  118. '[]',
  119. 'null',
  120. 'true',
  121. '1',
  122. '"string"',
  123. ] as $invalid) {
  124. rejects(fn () => Input::json(req('POST', '/api/mini/v1/test', $invalid, ['Content-Type' => 'application/json'])), 40001);
  125. }
  126. rejects(fn () => Input::json(req('POST', '/api/mini/v1/test', '{}', ['Content-Type' => 'text/plain'])), 41501);
  127. foreach ([
  128. true,
  129. [],
  130. 1.5,
  131. 0,
  132. 101,
  133. 'bad',
  134. ] as $invalid) {
  135. rejects(fn () => new Pagination(['page_size' => $invalid]), 42201);
  136. }
  137. rejects(fn () => new Pagination(['page' => 0]), 42201);
  138. $pagination = new Pagination([
  139. 'page' => '2',
  140. 'page_size' => '10',
  141. ]);
  142. check($pagination->offset() === 10 && $pagination->result([['id' => 1]], 12)['has_more'], 'pagination beyond first page');
  143. check((new Pagination([]))->result([], 0) === [
  144. 'items' => [],
  145. 'total' => 0,
  146. 'page' => 1,
  147. 'page_size' => 20,
  148. 'has_more' => false,
  149. ], 'empty page contract');
  150. foreach ([
  151. '',
  152. 'Basic fixture-only',
  153. 'Bearer',
  154. 'Bearer token with space',
  155. 'Bearer ' . str_repeat('x', 4100),
  156. ] as $bad) {
  157. rejects(fn () => $auth->process(req('GET', '/api/mini/v1/session', '', ['Authorization' => $bad]), fn ($r) => $controller->session($r)), 40101);
  158. }
  159. settings(['token_verifier' => null]);
  160. rejects(fn () => $auth->process(req('GET', '/api/mini/v1/session', '', ['Authorization' => 'Bearer admin.jwt.token']), fn ($r) => $controller->session($r)), 50301);
  161. rejects(fn () => $controller->session(req('GET', '/api/mini/v1/session?user_id=1')), 40101);
  162. settings(['token_verifier' => FixtureVerifier::class]);
  163. $authenticated = req('GET', '/api/mini/v1/session?user_id=1&enterprise_id=1', '', ['Authorization' => 'Bearer fixture-only']);
  164. $result = $auth->process($authenticated, fn ($r) => $controller->session($r));
  165. check(json_decode($result->rawBody(), true)['data'] === ['user_id' => 'verified-user'], 'only verified identity accepted');
  166. $forged = req('GET', '/api/mini/v1/session', '', ['Authorization' => 'Bearer wrong']);
  167. $forged->miniPrincipal = new Principal('previous-user');
  168. rejects(fn () => $auth->process($forged, fn ($r) => $controller->session($r)), 40101);
  169. check($forged->miniPrincipal === null, 'failed authentication cannot retain old identity');
  170. $rendered = $errors->render(req(), new RuntimeException('secret-token database-password'));
  171. check($rendered->getStatusCode() === 500 && !str_contains($rendered->rawBody(), 'secret') && !str_contains($rendered->rawBody(), 'traces'), 'unexpected failures sanitized even in debug');
  172. $validation = $errors->render(req(), new ApiException(42201, '参数校验失败', 422, ['page' => '必须是整数']));
  173. check($validation->getStatusCode() === 422 && json_decode($validation->rawBody(), true)['data']['fields']['page'] === '必须是整数', 'validation details use same envelope');
  174. check($controller->methodNotAllowed(req('POST'))->getStatusCode() === 405 && $controller->notFound(req())->getStatusCode() === 404, 'HTTP error statuses');
  175. $annotation = (new ReflectionClass(SystemController::class))->getAttributes(\support\annotation\route\DisableDefaultRoute::class);
  176. check(count($annotation) === 1, 'implicit controller routes disabled');
  177. } finally {
  178. settings($original);
  179. }
  180. if (in_array('--http', $argv, true)) {
  181. foreach ([
  182. [
  183. 'GET',
  184. '/api/mini/v1/health',
  185. 200,
  186. 0,
  187. [],
  188. ],
  189. [
  190. 'GET',
  191. '/api/mini/v1/session',
  192. 401,
  193. 40101,
  194. [],
  195. ],
  196. [
  197. 'GET',
  198. '/api/mini/v1/session',
  199. 401,
  200. 40101,
  201. ['Authorization: Bearer admin.jwt.token'],
  202. ],
  203. [
  204. 'POST',
  205. '/api/mini/v1/health',
  206. 405,
  207. 40501,
  208. [],
  209. ],
  210. [
  211. 'GET',
  212. '/api/mini/v1/missing',
  213. 404,
  214. 40401,
  215. [],
  216. ],
  217. [
  218. 'GET',
  219. '/api/mini/v2/health',
  220. 404,
  221. 40401,
  222. [],
  223. ],
  224. [
  225. 'GET',
  226. '/api/mini',
  227. 404,
  228. 40401,
  229. [],
  230. ],
  231. [
  232. 'GET',
  233. '/api/mini/v1/health',
  234. 403,
  235. 40301,
  236. ['Origin: https://untrusted.invalid'],
  237. ],
  238. ] as [
  239. $method,
  240. $path,
  241. $status,
  242. $code,
  243. $headers
  244. ]) {
  245. $ch = curl_init('http://127.0.0.1:8787' . $path);
  246. curl_setopt_array($ch, [
  247. CURLOPT_RETURNTRANSFER => true,
  248. CURLOPT_TIMEOUT => 10,
  249. CURLOPT_CUSTOMREQUEST => $method,
  250. CURLOPT_HTTPHEADER => $headers,
  251. ]);
  252. $raw = curl_exec($ch);
  253. $actual = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  254. curl_close($ch);
  255. $body = json_decode((string) $raw, true);
  256. check($actual === $status && ($body['code'] ?? null) === $code, 'HTTP ' . $method . ' ' . $path . ' expected ' . $status . ' got ' . $actual);
  257. check(isset($body['request_id']) && array_key_exists('data', $body) && !isset($body['traces']), 'HTTP uniform envelope');
  258. }
  259. $ch = curl_init('http://127.0.0.1:8787/miniapi/system/session');
  260. curl_setopt_array($ch, [
  261. CURLOPT_RETURNTRANSFER => true,
  262. CURLOPT_TIMEOUT => 10,
  263. ]);
  264. curl_exec($ch);
  265. $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  266. curl_close($ch);
  267. check($status !== 200, 'HTTP implicit session route unavailable');
  268. }
  269. echo "PASS: $checks mini API framework checks; no persistent data changes.\n";