ApiCryptoTest.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Http\Controllers\api\TelegramWebHook;
  4. use App\Services\ApiCryptoService;
  5. use App\Services\UserService;
  6. use Illuminate\Contracts\Http\Kernel;
  7. use Illuminate\Database\Schema\Blueprint;
  8. use Illuminate\Foundation\Application;
  9. use Illuminate\Http\JsonResponse;
  10. use Illuminate\Http\Request;
  11. use Illuminate\Support\Facades\DB;
  12. use Illuminate\Support\Facades\Facade;
  13. use Illuminate\Support\Facades\Route;
  14. use Illuminate\Support\Facades\Schema;
  15. use PHPUnit\Framework\TestCase;
  16. use Symfony\Component\HttpFoundation\Response;
  17. class ApiCryptoTest extends TestCase
  18. {
  19. private Application $app;
  20. private Kernel $kernel;
  21. private ApiCryptoService $crypto;
  22. private int $errorReporting;
  23. private string $displayErrors;
  24. private const TOKEN = 'fixture-member-token';
  25. protected function setUp(): void
  26. {
  27. Facade::clearResolvedInstances();
  28. $this->errorReporting = error_reporting();
  29. $this->displayErrors = ini_get('display_errors');
  30. $this->app = require dirname(__DIR__, 2) . '/bootstrap/app.php';
  31. $this->kernel = $this->app->make(Kernel::class);
  32. $this->kernel->bootstrap();
  33. $key = base64_encode(str_repeat('k', 32));
  34. $this->crypto = new ApiCryptoService($key);
  35. config([
  36. 'app.env' => 'testing',
  37. 'app.debug' => false,
  38. 'app.key' => 'base64:' . $key,
  39. 'api_crypto.enabled' => true,
  40. 'api_crypto.key' => $key,
  41. 'api_crypto.allow_plaintext' => false,
  42. 'api_crypto.timestamp_tolerance' => 300,
  43. 'api_crypto.max_payload_bytes' => 2097152,
  44. 'cache.default' => 'array',
  45. 'database.default' => 'sqlite',
  46. 'database.connections.sqlite.url' => null,
  47. 'database.connections.sqlite.database' => ':memory:',
  48. 'database.connections.sqlite.prefix' => 'bot_',
  49. 'logging.default' => 'null',
  50. 'logging.channels.dblog' => config('logging.channels.null'),
  51. 'app.tree_payment_merchant_id' => '',
  52. 'app.tree_pay_mch_id' => '',
  53. 'app.jd_pay_mch_id' => '',
  54. ]);
  55. }
  56. protected function tearDown(): void
  57. {
  58. DB::disconnect();
  59. Facade::clearResolvedInstances();
  60. $this->app->flush();
  61. restore_error_handler();
  62. restore_exception_handler();
  63. error_reporting($this->errorReporting);
  64. ini_set('display_errors', $this->displayErrors);
  65. parent::tearDown();
  66. }
  67. public function test_real_wallet_and_lottery_routes_keep_the_original_business_response(): void
  68. {
  69. $this->createWalletFixture();
  70. Schema::create('pc_issues', function (Blueprint $table) {
  71. $table->id();
  72. $table->integer('status');
  73. $table->string('issue_no');
  74. });
  75. $response = $this->sendEncrypted('/api/wallet/withdrawChannel?lang=zh', 'GET', ['lang' => 'en'], true);
  76. $body = $this->unwrap($response);
  77. self::assertSame(0, $body['code']);
  78. self::assertSame(['code', 'timestamp', 'msg', 'data'], array_keys($body));
  79. self::assertSame('Bank card', array_column($body['data'], 'label', 'value')['DF001']);
  80. $lottery = $this->unwrap($this->sendEncrypted('/api/newPc/yuanTou', 'GET', (object) []));
  81. self::assertSame(0, $lottery['code'] ?? null, json_encode($lottery));
  82. self::assertSame([], $lottery['data']);
  83. $response = $this->sendEncrypted('/api/wallet/payout?amount=900', 'POST', [
  84. 'channel' => 'NOwithdraw', 'amount' => 100, 'card_no' => ' no_fixture ', 'safe_word' => 'fixture-password',
  85. ], true, ['card_no' => 'outer-account']);
  86. $body = $this->unwrap($response);
  87. self::assertSame(0, $body['code'] ?? null, json_encode($body));
  88. $order = DB::table('payment_orders')->sole();
  89. self::assertSame('NOwithdraw', $order->channel);
  90. self::assertSame('no_fixture', $order->card_no);
  91. self::assertSame(100, (int) $order->amount);
  92. DB::table('user_session')->where('token', self::TOKEN)->update(['expire_time' => time() - 1]);
  93. $expiredSession = $this->sendEncrypted('/api/wallet/withdrawChannel', 'GET', (object) [], true);
  94. self::assertSame(401, $expiredSession->getStatusCode());
  95. self::assertSame(401, $this->unwrap($expiredSession)['code']);
  96. }
  97. public function test_authentication_and_maintenance_errors_remain_encrypted(): void
  98. {
  99. $this->createMaintenanceFixture();
  100. $response = $this->sendEncrypted('/api/wallet/withdrawChannel', 'GET', (object) []);
  101. self::assertSame(401, $response->getStatusCode());
  102. self::assertSame(401, $this->unwrap($response)['code']);
  103. DB::table('maintenance_settings')->where('scope_code', 'site')->update(['enabled' => 1, 'platforms' => '["H5"]']);
  104. $response = $this->sendEncrypted('/api/wallet/withdrawChannel?platform=PC', 'GET', ['platform' => 'H5']);
  105. self::assertSame(200, $response->getStatusCode());
  106. self::assertSame(11001, $this->unwrap($response)['code']);
  107. }
  108. public function test_registered_real_name_is_used_for_binding_and_payout(): void
  109. {
  110. $this->createWalletFixture();
  111. DB::table('users')->where('member_id', '42')->update(['real_name' => 'Registered Name']);
  112. $binding = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', [
  113. 'channel' => 'DF001', 'bank_name' => 'Fixture Bank', 'card_no' => '12345', 'account' => 'Different Name',
  114. ], true));
  115. self::assertSame('Registered Name', $binding['data']['real_name']);
  116. self::assertSame('Registered Name', DB::table('banks')->sole()->account);
  117. $payout = $this->unwrap($this->sendEncrypted('/api/wallet/payout', 'POST', [
  118. 'channel' => 'DF001', 'bank_name' => 'Fixture Bank', 'card_no' => '12345',
  119. 'amount' => 100, 'safe_word' => 'fixture-password', 'account' => 'Another Name',
  120. ], true));
  121. self::assertSame(0, $payout['code'], $payout['msg']);
  122. self::assertSame('Registered Name', DB::table('payment_orders')->sole()->account);
  123. self::assertSame('Registered Name', UserService::paginate()['data']->first()->toArray()['real_name']);
  124. self::assertArrayNotHasKey('real_name', \App\Models\User::first()->toArray());
  125. }
  126. public function test_only_the_first_successful_named_binding_can_set_a_missing_real_name(): void
  127. {
  128. $this->createWalletFixture();
  129. $params = ['channel' => 'DF001', 'bank_name' => 'Fixture Bank', 'card_no' => '12345'];
  130. $payout = $this->unwrap($this->sendEncrypted('/api/wallet/payout', 'POST', $params + [
  131. 'amount' => 100, 'safe_word' => 'fixture-password', 'account' => 'Unbound Name',
  132. ], true));
  133. self::assertSame('请先绑定收款账户并填写真实姓名', $payout['msg']);
  134. self::assertSame(0, DB::table('payment_orders')->count());
  135. $invalid = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', $params + [
  136. 'id' => 999, 'account' => 'Invalid Binding',
  137. ], true));
  138. self::assertNotSame(0, $invalid['code']);
  139. self::assertNull(DB::table('users')->value('real_name'));
  140. DB::unprepared("CREATE TRIGGER reject_bank BEFORE INSERT ON bot_banks
  141. WHEN NEW.card_no = 'reject-fixture' BEGIN SELECT RAISE(ABORT, 'fixture failure'); END");
  142. $failedSave = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', [
  143. 'card_no' => 'reject-fixture', 'account' => 'Failed Name',
  144. ] + $params, true));
  145. self::assertNotSame(0, $failedSave['code']);
  146. self::assertNull(DB::table('users')->value('real_name'));
  147. $noName = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', [
  148. 'channel' => 'NOwithdraw', 'bank_name' => 'NO Wallet', 'card_no' => 'no_fixture',
  149. ], true));
  150. self::assertSame(0, $noName['code']);
  151. self::assertNull($noName['data']['real_name']);
  152. $binding = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', $params + [
  153. 'account' => " First Bound Name ",
  154. ], true));
  155. self::assertSame('First Bound Name', $binding['data']['real_name']);
  156. $id = DB::table('banks')->where('channel', 'DF001')->value('id');
  157. $update = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', $params + [
  158. 'id' => $id, 'account' => 'Replacement Name',
  159. ], true));
  160. self::assertSame('First Bound Name', $update['data']['real_name']);
  161. self::assertSame('First Bound Name', DB::table('banks')->where('id', $id)->value('account'));
  162. $deleted = $this->unwrap($this->sendEncrypted('/api/wallet/delBank', 'GET', ['id' => $id], true));
  163. self::assertSame(0, $deleted['code']);
  164. $list = $this->unwrap($this->sendEncrypted('/api/wallet/bankList', 'GET', (object) [], true));
  165. self::assertSame('First Bound Name', $list['data']['real_name']);
  166. self::assertSame('First Bound Name', $list['data']['list'][0]['account']);
  167. }
  168. public function test_protocol_errors_and_rollout_flags(): void
  169. {
  170. Route::get('api/wallet/probe', fn () => response()->json(['code' => 0]));
  171. foreach (['/api/wallet/probe', '/api/newPc', '/api/issue', '/api/pcissue'] as $path) {
  172. $response = $this->send($path);
  173. self::assertSame(400, $response->getStatusCode());
  174. self::assertSame(4001, $this->unwrap($response)['code']);
  175. }
  176. $payload = $this->crypto->encrypt(['amount' => 100], 'request');
  177. $envelope = json_decode(base64_decode(strtr($payload, '-_', '+/')), true);
  178. $envelope['tag'][0] = $envelope['tag'][0] === 'A' ? 'B' : 'A';
  179. $tampered = rtrim(strtr(base64_encode(json_encode($envelope)), '+/', '-_'), '=');
  180. $expired = $this->crypto->encrypt((object) [], 'request', (time() - 301) * 1000);
  181. $wrongDirection = $this->crypto->encrypt((object) [], 'response');
  182. foreach (['', $tampered, $expired, $wrongDirection, $this->crypto->encrypt([], 'request')] as $invalid) {
  183. $response = $this->send('/api/wallet/probe?payload=' . $invalid, 'GET', ['HTTP_X_API_ENCRYPTED' => '1']);
  184. self::assertSame(400, $response->getStatusCode());
  185. self::assertSame(4002, $this->unwrap($response)['code']);
  186. }
  187. Route::get('api/wallet/large-response', fn () => response()->json(['data' => str_repeat('x', 1024)]));
  188. config(['api_crypto.max_payload_bytes' => 1024]);
  189. $response = $this->sendEncrypted('/api/wallet/large-response', 'GET', (object) []);
  190. self::assertSame(500, $response->getStatusCode());
  191. self::assertSame(5002, json_decode($response->getContent(), true)['code']);
  192. config(['api_crypto.allow_plaintext' => true]);
  193. self::assertSame('{"code":0}', $this->send('/api/wallet/probe')->getContent());
  194. self::assertSame(0, $this->unwrap($this->sendEncrypted('/api/wallet/probe', 'GET', (object) []))['code']);
  195. config(['api_crypto.key' => 'invalid']);
  196. $response = $this->send('/api/wallet/probe');
  197. self::assertSame(500, $response->getStatusCode());
  198. self::assertSame(5001, json_decode($response->getContent(), true)['code']);
  199. config(['api_crypto.enabled' => false]);
  200. self::assertSame('{"code":0}', $this->send('/api/wallet/probe')->getContent());
  201. }
  202. public function test_cors_preflight_and_head(): void
  203. {
  204. Route::get('api/wallet/probe', fn () => response()->json(['code' => 0]));
  205. $response = $this->send('/api/wallet/probe', 'OPTIONS', [
  206. 'HTTP_ORIGIN' => 'https://frontend.example',
  207. 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST',
  208. 'HTTP_ACCESS_CONTROL_REQUEST_HEADERS' => 'content-type,x-api-encrypted',
  209. ]);
  210. self::assertSame(204, $response->getStatusCode());
  211. self::assertStringContainsString('x-api-encrypted', strtolower($response->headers->get('Access-Control-Allow-Headers')));
  212. self::assertFalse($response->headers->has('X-Api-Encrypted'));
  213. $response = $this->sendEncrypted('/api/wallet/probe', 'HEAD', (object) []);
  214. self::assertSame(200, $response->getStatusCode());
  215. self::assertSame('1', $response->headers->get('X-Api-Encrypted'));
  216. self::assertStringContainsString('X-Api-Encrypted', $response->headers->get('Access-Control-Expose-Headers'));
  217. self::assertSame('', $response->getContent());
  218. }
  219. public function test_excluded_routes_keep_plaintext_input_and_response(): void
  220. {
  221. $this->createMaintenanceFixture();
  222. (require dirname(__DIR__, 2) . '/database/migrations/2026_09_08_160000_add_payment_provider_status_and_channel_soft_deletes.php')->up();
  223. $this->app->instance(TelegramWebHook::class, new class extends TelegramWebHook {
  224. public function __construct() {}
  225. public function handle(Request $request): JsonResponse
  226. {
  227. return response()->json(['update_id' => $request->input('update_id')]);
  228. }
  229. });
  230. $telegram = $this->send('/api/onMessage', 'POST', ['CONTENT_TYPE' => 'application/json'], '{"update_id":123}');
  231. self::assertSame('{"update_id":123}', $telegram->getContent());
  232. self::assertFalse($telegram->headers->has('X-Api-Encrypted'));
  233. foreach (['notify', 'harvest'] as $callback) {
  234. $response = $this->send('/api/pay/' . $callback, 'POST', ['CONTENT_TYPE' => 'application/json'],
  235. '{"appId":"unmatched-fixture","merchantNum":"unmatched-fixture","userCode":"unmatched-fixture","mchId":"unmatched-fixture"}');
  236. self::assertSame(200, $response->getStatusCode(), $callback . ': ' . $response->getContent());
  237. self::assertSame('success', $response->getContent());
  238. self::assertFalse($response->headers->has('X-Api-Encrypted'));
  239. }
  240. Schema::create('config', function (Blueprint $table) {
  241. $table->id();
  242. $table->string('field');
  243. $table->string('val');
  244. });
  245. DB::table('config')->insert([
  246. ['field' => 'activity_rule', 'val' => 'Telegram rule fixture'],
  247. ['field' => 'benefits_service_customer', 'val' => 'customer fixture'],
  248. ]);
  249. $activity = $this->send('/api/ActivityReward/rule');
  250. self::assertSame(200, $activity->getStatusCode());
  251. self::assertSame('Telegram rule fixture', json_decode($activity->getContent(), true)['data']['rule']);
  252. self::assertFalse($activity->headers->has('X-Api-Encrypted'));
  253. $admin = $this->send('/admin/maintenance/options');
  254. self::assertFalse($admin->headers->has('X-Api-Encrypted'));
  255. }
  256. private function send(string $url, string $method = 'GET', array $headers = [], ?string $body = null): Response
  257. {
  258. return $this->kernel->handle(Request::create($url, $method, [], [], [], $headers + [
  259. 'HTTP_ORIGIN' => 'https://frontend.example',
  260. 'HTTP_ACCEPT' => 'application/json',
  261. ], $body));
  262. }
  263. private function sendEncrypted(string $url, string $method, array|object $parameters, bool $authenticated = false, array $outerBody = []): Response
  264. {
  265. $payload = $this->crypto->encrypt($parameters, 'request');
  266. $headers = ['HTTP_X_API_ENCRYPTED' => '1'];
  267. if ($authenticated) {
  268. $headers['HTTP_AUTHORIZATION'] = self::TOKEN;
  269. }
  270. if ($method === 'POST') {
  271. return $this->send($url, $method, $headers + ['CONTENT_TYPE' => 'application/json'], json_encode(['payload' => $payload] + $outerBody));
  272. }
  273. return $this->send($url . (str_contains($url, '?') ? '&' : '?') . 'payload=' . $payload, $method, $headers);
  274. }
  275. private function unwrap(Response $response): array
  276. {
  277. self::assertSame('1', $response->headers->get('X-Api-Encrypted'), (string) $response->getContent());
  278. self::assertStringContainsString('no-store', $response->headers->get('Cache-Control'));
  279. $envelope = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
  280. self::assertTrue($envelope['encrypted']);
  281. return $this->crypto->decrypt($envelope['payload'], 'response');
  282. }
  283. private function createMaintenanceFixture(): void
  284. {
  285. (require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_120000_create_maintenance_settings.php')->up();
  286. }
  287. private function createWalletFixture(): void
  288. {
  289. $this->createMaintenanceFixture();
  290. Schema::create('users', function (Blueprint $table) {
  291. $table->id();
  292. $table->string('user_id');
  293. $table->string('member_id');
  294. $table->integer('from');
  295. $table->integer('recharge_channel_group_id');
  296. $table->string('payment_password');
  297. $table->integer('level')->default(1);
  298. $table->string('agent_user_code')->nullable();
  299. $table->timestamps();
  300. });
  301. (require dirname(__DIR__, 2) . '/database/migrations/2026_11_06_120000_add_real_name_to_users.php')->up();
  302. Schema::create('level', function (Blueprint $table) {
  303. $table->id();
  304. $table->integer('level');
  305. $table->string('level_name');
  306. $table->string('img');
  307. $table->decimal('recharge');
  308. });
  309. Schema::create('user_session', function (Blueprint $table) {
  310. $table->id();
  311. $table->string('user_id');
  312. $table->string('token');
  313. $table->bigInteger('expire_time');
  314. $table->bigInteger('create_time');
  315. $table->bigInteger('update_time');
  316. });
  317. Schema::create('recharge_channel_group', function (Blueprint $table) {
  318. $table->id();
  319. $table->string('withdraw_type');
  320. $table->softDeletes();
  321. });
  322. Schema::create('recharge_channel', function (Blueprint $table) {
  323. $table->id();
  324. $table->integer('data_type');
  325. $table->integer('status');
  326. $table->integer('sort');
  327. $table->string('name');
  328. $table->string('type');
  329. $table->decimal('rate');
  330. $table->decimal('min');
  331. $table->decimal('max');
  332. $table->string('fixed')->nullable();
  333. $table->string('logo')->nullable();
  334. });
  335. (require dirname(__DIR__, 2) . '/database/migrations/2026_09_08_160000_add_payment_provider_status_and_channel_soft_deletes.php')->up();
  336. Schema::create('wallets', function (Blueprint $table) {
  337. $table->id();
  338. $table->string('member_id');
  339. $table->decimal('available_balance');
  340. $table->decimal('frozen_balance')->default(0);
  341. $table->timestamps();
  342. });
  343. Schema::create('payment_orders', function (Blueprint $table) {
  344. $table->id();
  345. $table->integer('type');
  346. $table->integer('status');
  347. foreach (['member_id', 'order_no', 'channel', 'bank_name', 'account', 'card_no', 'callback_url', 'remark'] as $field) {
  348. $table->string($field);
  349. }
  350. $table->decimal('amount');
  351. $table->decimal('fee');
  352. $table->timestamps();
  353. });
  354. Schema::create('backflow', function (Blueprint $table) {
  355. $table->id();
  356. $table->string('member_id');
  357. $table->string('date');
  358. $table->integer('status')->default(0);
  359. $table->decimal('withdrawal_amount')->default(0);
  360. $table->timestamps();
  361. });
  362. Schema::create('balance_logs', function (Blueprint $table) {
  363. $table->id();
  364. $table->integer('type')->default(1);
  365. $table->string('member_id');
  366. $table->string('change_type');
  367. $table->integer('related_id');
  368. $table->string('remark');
  369. $table->decimal('amount');
  370. $table->decimal('before_balance');
  371. $table->decimal('after_balance');
  372. $table->timestamps();
  373. });
  374. Schema::create('banks', function (Blueprint $table) {
  375. $table->id();
  376. foreach (['member_id', 'channel', 'bank_name', 'account', 'card_no', 'alias'] as $field) {
  377. $table->string($field);
  378. }
  379. $table->timestamps();
  380. });
  381. DB::table('users')->insert(['user_id' => '42', 'member_id' => '42', 'from' => 1, 'recharge_channel_group_id' => 1,
  382. 'payment_password' => password_hash('fixture-password', PASSWORD_DEFAULT)]);
  383. DB::table('user_session')->insert(['user_id' => '42', 'token' => self::TOKEN,
  384. 'expire_time' => time() + 3600, 'create_time' => time(), 'update_time' => time()]);
  385. DB::table('wallets')->insert(['member_id' => '42', 'available_balance' => 1000]);
  386. DB::table('recharge_channel_group')->insert(['id' => 1, 'withdraw_type' => 'DF001,NOwithdraw']);
  387. foreach (['DF001' => '银行卡', 'NOwithdraw' => 'NO快捷提现'] as $type => $name) {
  388. DB::table('recharge_channel')->insert(['data_type' => 2, 'status' => 1, 'sort' => 0,
  389. 'name' => $name, 'type' => $type, 'rate' => 0, 'min' => 10, 'max' => 5000]);
  390. }
  391. }
  392. }