| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276 |
- <?php
- /** Framework-only checks: no user, inventory, payment or database writes. */
- 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\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";
|