load(); \support\App::loadAllConfig(['route']); use app\miniapi\auth\Principal; use app\miniapi\auth\TokenVerifier; use app\miniapi\controller\SystemController; use app\miniapi\exception\ApiException; use app\miniapi\exception\Handler; use app\miniapi\http\ApiResponse; use app\miniapi\http\Input; use app\miniapi\http\Pagination; use app\miniapi\middleware\ApiBoundary; use app\miniapi\middleware\Authenticate; use support\Request; $checks = 0; function check(bool $ok, string $label): void { global $checks; $checks++; if (!$ok) { throw new RuntimeException($label); } } function rejects(callable $fn, int $code): void { try { $fn(); } catch (ApiException $e) { check($e->getCode() === $code, 'expected error ' . $code); return; }throw new RuntimeException('Missing rejection ' . $code); } function req(string $method = 'GET', string $path = '/api/mini/v1/health', string $body = '', array $headers = []): Request { $head = ''; foreach ($headers as $k => $v) { $head .= $k . ': ' . $v . "\r\n"; } return new Request($method . ' ' . $path . " HTTP/1.1\r\nHost: localhost\r\n" . $head . 'Content-Length: ' . strlen($body) . "\r\n\r\n" . $body); } function settings(array $changes): void { $property = new ReflectionProperty(\Webman\Config::class, 'config'); $config = $property->getValue(); $config['mini_api'] = array_replace($config['mini_api'], $changes); $property->setValue(null, $config); (new ReflectionProperty(\Webman\Config::class, 'flatCache'))->setValue(null, []); } final class FixtureVerifier implements TokenVerifier { public function verify(string $token): Principal { if ($token !== 'fixture-only') { throw new ApiException(40101, '登录状态已失效', 401); } return new Principal('verified-user'); } } $original = config('mini_api'); $controller = new SystemController(); $boundary = new ApiBoundary(); $auth = new Authenticate(); $errors = new Handler(new \Psr\Log\NullLogger(), true); try { $rateFile = tempnam(sys_get_temp_dir(), 'mini-rate-'); settings([ 'login_throttle_file' => $rateFile, 'login_ip_per_minute' => 2, 'login_global_per_minute' => 3, ]); try { $throttle = new \app\miniapi\auth\LoginThrottle(); $throttle->consume('127.0.0.1'); $throttle->consume('127.0.0.1'); rejects(fn () => $throttle->consume('127.0.0.1'), 42901); $throttle->consume('127.0.0.2'); rejects(fn () => $throttle->consume('127.0.0.3'), 42901); file_put_contents($rateFile, json_encode([ 'window' => intdiv(time(), 60) - 1, 'total' => 999, 'clients' => [], ])); $throttle->consume('127.0.0.1'); check(json_decode(file_get_contents($rateFile), true)['total'] === 1, 'expired login throttle window resets'); } finally { unlink($rateFile); settings($original); } $request = req(); $response = $boundary->process($request, fn ($r) => $controller->health($r)); $body = json_decode($response->rawBody(), true); check($response->getStatusCode() === 200 && $body['code'] === 0 && $body['data']['version'] === 'v1', 'health contract'); check($body['data']['capabilities']['business'] && !$body['data']['capabilities']['wechat_payment'], 'accurate business availability'); check(preg_match('/^[a-f0-9]{32}$/', $body['request_id']) === 1 && $response->getHeader('X-Request-Id') === $body['request_id'], 'request correlation'); $other = req('GET', '/api/mini/v1/health', '', ['X-Request-Id' => 'attacker-controlled']); $otherBody = json_decode($boundary->process($other, fn ($r) => $controller->health($r))->rawBody(), true); check($otherBody['request_id'] !== $body['request_id'] && $otherBody['request_id'] !== 'attacker-controlled', 'fresh server-generated request ID'); $legacy = req('GET', '/core/water/overview'); $unchanged = new \support\Response(202, [], 'existing'); check($boundary->process($legacy, fn () => $unchanged) === $unchanged && !isset($legacy->miniRequestId), 'non-mini middleware unchanged'); check(!ApiResponse::owns(req('GET', '/api/minimum')) && ApiResponse::owns(req('GET', '/api/mini')), 'prefix boundary'); 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'); settings(['allowed_origins' => ['https://app.example.test']]); $preflight = $boundary->process(req('OPTIONS', '/api/mini/v1/session', '', ['Origin' => 'https://app.example.test']), fn () => throw new RuntimeException('must not authenticate OPTIONS')); check($preflight->getStatusCode() === 204 && $preflight->getHeader('Access-Control-Allow-Origin') === 'https://app.example.test', 'configured preflight'); check($preflight->getHeader('Access-Control-Allow-Credentials') === null, 'no cookie credentials enabled'); settings(['max_body_bytes' => 4]); check($boundary->process(req('POST', '/api/mini/v1/test', '12345'), fn () => throw new RuntimeException('too large'))->getStatusCode() === 413, 'body limit before handler'); rejects(fn () => Input::json(req('POST', '/api/mini/v1/test', '12345', ['Content-Type' => 'application/json'])), 41301); settings($original); 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'); check(Input::json(req('POST', '/api/mini/v1/test', '{}', ['Content-Type' => 'application/json'])) === [], 'empty object allowed'); foreach ([ '', '{', '[]', 'null', 'true', '1', '"string"', ] as $invalid) { rejects(fn () => Input::json(req('POST', '/api/mini/v1/test', $invalid, ['Content-Type' => 'application/json'])), 40001); } rejects(fn () => Input::json(req('POST', '/api/mini/v1/test', '{}', ['Content-Type' => 'text/plain'])), 41501); foreach ([ true, [], 1.5, 0, 101, 'bad', ] as $invalid) { rejects(fn () => new Pagination(['page_size' => $invalid]), 42201); } rejects(fn () => new Pagination(['page' => 0]), 42201); $pagination = new Pagination([ 'page' => '2', 'page_size' => '10', ]); check($pagination->offset() === 10 && $pagination->result([['id' => 1]], 12)['has_more'], 'pagination beyond first page'); check((new Pagination([]))->result([], 0) === [ 'items' => [], 'total' => 0, 'page' => 1, 'page_size' => 20, 'has_more' => false, ], 'empty page contract'); foreach ([ '', 'Basic fixture-only', 'Bearer', 'Bearer token with space', 'Bearer ' . str_repeat('x', 4100), ] as $bad) { rejects(fn () => $auth->process(req('GET', '/api/mini/v1/session', '', ['Authorization' => $bad]), fn ($r) => $controller->session($r)), 40101); } settings(['token_verifier' => null]); rejects(fn () => $auth->process(req('GET', '/api/mini/v1/session', '', ['Authorization' => 'Bearer admin.jwt.token']), fn ($r) => $controller->session($r)), 50301); rejects(fn () => $controller->session(req('GET', '/api/mini/v1/session?user_id=1')), 40101); settings(['token_verifier' => FixtureVerifier::class]); $authenticated = req('GET', '/api/mini/v1/session?user_id=1&enterprise_id=1', '', ['Authorization' => 'Bearer fixture-only']); $result = $auth->process($authenticated, fn ($r) => $controller->session($r)); check(json_decode($result->rawBody(), true)['data'] === ['user_id' => 'verified-user'], 'only verified identity accepted'); $forged = req('GET', '/api/mini/v1/session', '', ['Authorization' => 'Bearer wrong']); $forged->miniPrincipal = new Principal('previous-user'); rejects(fn () => $auth->process($forged, fn ($r) => $controller->session($r)), 40101); check($forged->miniPrincipal === null, 'failed authentication cannot retain old identity'); $rendered = $errors->render(req(), new RuntimeException('secret-token database-password')); check($rendered->getStatusCode() === 500 && !str_contains($rendered->rawBody(), 'secret') && !str_contains($rendered->rawBody(), 'traces'), 'unexpected failures sanitized even in debug'); $validation = $errors->render(req(), new ApiException(42201, '参数校验失败', 422, ['page' => '必须是整数'])); check($validation->getStatusCode() === 422 && json_decode($validation->rawBody(), true)['data']['fields']['page'] === '必须是整数', 'validation details use same envelope'); check($controller->methodNotAllowed(req('POST'))->getStatusCode() === 405 && $controller->notFound(req())->getStatusCode() === 404, 'HTTP error statuses'); $annotation = (new ReflectionClass(SystemController::class))->getAttributes(\support\annotation\route\DisableDefaultRoute::class); check(count($annotation) === 1, 'implicit controller routes disabled'); } finally { settings($original); } if (in_array('--http', $argv, true)) { foreach ([ [ 'GET', '/api/mini/v1/health', 200, 0, [], ], [ 'GET', '/api/mini/v1/session', 401, 40101, [], ], [ 'GET', '/api/mini/v1/session', 401, 40101, ['Authorization: Bearer admin.jwt.token'], ], [ 'POST', '/api/mini/v1/health', 405, 40501, [], ], [ 'GET', '/api/mini/v1/missing', 404, 40401, [], ], [ 'GET', '/api/mini/v2/health', 404, 40401, [], ], [ 'GET', '/api/mini', 404, 40401, [], ], [ 'GET', '/api/mini/v1/health', 403, 40301, ['Origin: https://untrusted.invalid'], ], ] as [ $method, $path, $status, $code, $headers ]) { $ch = curl_init('http://127.0.0.1:8787' . $path); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, ]); $raw = curl_exec($ch); $actual = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $body = json_decode((string) $raw, true); check($actual === $status && ($body['code'] ?? null) === $code, 'HTTP ' . $method . ' ' . $path . ' expected ' . $status . ' got ' . $actual); check(isset($body['request_id']) && array_key_exists('data', $body) && !isset($body['traces']), 'HTTP uniform envelope'); } $ch = curl_init('http://127.0.0.1:8787/miniapi/system/session'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, ]); curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); check($status !== 200, 'HTTP implicit session route unavailable'); } echo "PASS: $checks mini API framework checks; no persistent data changes.\n";