ApiCryptoTest.php 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Constants\StepStatus;
  4. use App\Http\Controllers\api\TelegramWebHook;
  5. use App\Services\ApiCryptoService;
  6. use App\Services\QianBaoWithdrawService;
  7. use App\Services\UserService;
  8. use App\Services\WithdrawService;
  9. use Illuminate\Contracts\Http\Kernel;
  10. use Illuminate\Database\Schema\Blueprint;
  11. use Illuminate\Foundation\Application;
  12. use Illuminate\Http\JsonResponse;
  13. use Illuminate\Http\Request;
  14. use Illuminate\Support\Facades\DB;
  15. use Illuminate\Support\Facades\Cache;
  16. use Illuminate\Support\Facades\Facade;
  17. use Illuminate\Support\Facades\Route;
  18. use Illuminate\Support\Facades\Schema;
  19. use PHPUnit\Framework\TestCase;
  20. use Symfony\Component\HttpFoundation\Response;
  21. class ApiCryptoTest extends TestCase
  22. {
  23. private Application $app;
  24. private Kernel $kernel;
  25. private ApiCryptoService $crypto;
  26. private int $errorReporting;
  27. private string $displayErrors;
  28. private const TOKEN = 'fixture-member-token';
  29. protected function setUp(): void
  30. {
  31. Facade::clearResolvedInstances();
  32. $this->errorReporting = error_reporting();
  33. $this->displayErrors = ini_get('display_errors');
  34. $this->app = require dirname(__DIR__, 2) . '/bootstrap/app.php';
  35. $this->kernel = $this->app->make(Kernel::class);
  36. $this->kernel->bootstrap();
  37. $key = base64_encode(str_repeat('k', 32));
  38. $this->crypto = new ApiCryptoService($key);
  39. config([
  40. 'app.env' => 'testing',
  41. 'app.debug' => false,
  42. 'app.key' => 'base64:' . $key,
  43. 'api_crypto.enabled' => true,
  44. 'api_crypto.key' => $key,
  45. 'api_crypto.allow_plaintext' => false,
  46. 'api_crypto.plaintext_user_ids' => [],
  47. 'api_crypto.timestamp_tolerance' => 300,
  48. 'api_crypto.max_payload_bytes' => 2097152,
  49. 'cache.default' => 'array',
  50. 'database.default' => 'sqlite',
  51. 'database.connections.sqlite.url' => null,
  52. 'database.connections.sqlite.database' => ':memory:',
  53. 'database.connections.sqlite.prefix' => 'bot_',
  54. 'logging.default' => 'null',
  55. 'logging.channels.dblog' => config('logging.channels.null'),
  56. 'app.tree_payment_merchant_id' => '',
  57. 'app.tree_pay_mch_id' => '',
  58. 'app.jd_pay_mch_id' => '',
  59. ]);
  60. }
  61. protected function tearDown(): void
  62. {
  63. DB::disconnect();
  64. Facade::clearResolvedInstances();
  65. $this->app->flush();
  66. restore_error_handler();
  67. restore_exception_handler();
  68. error_reporting($this->errorReporting);
  69. ini_set('display_errors', $this->displayErrors);
  70. parent::tearDown();
  71. }
  72. public function test_real_wallet_and_lottery_routes_keep_the_original_business_response(): void
  73. {
  74. $this->createWalletFixture();
  75. Schema::create('pc_issues', function (Blueprint $table) {
  76. $table->id();
  77. $table->integer('status');
  78. $table->string('issue_no');
  79. });
  80. $response = $this->sendEncrypted('/api/wallet/withdrawChannel?lang=zh', 'GET', ['lang' => 'en'], true);
  81. $body = $this->unwrap($response);
  82. self::assertSame(0, $body['code']);
  83. self::assertSame(['code', 'timestamp', 'msg', 'data'], array_keys($body));
  84. self::assertSame('Bank card', array_column($body['data'], 'label', 'value')['DF001']);
  85. $channels = array_column($body['data'], null, 'value');
  86. self::assertTrue($channels['DF001']['requires_bank_name']);
  87. self::assertTrue($channels['DF001']['requires_bank_branch']);
  88. self::assertSame('Bank name', $channels['DF001']['bank_name_label']);
  89. self::assertTrue($channels['NOwithdraw']['requires_real_name']);
  90. self::assertTrue($channels['ZIMUwithdraw']['requires_real_name']);
  91. self::assertTrue($channels['JDpay']['requires_real_name']);
  92. self::assertTrue($channels['usdt']['requires_real_name']);
  93. self::assertSame(['bank', 'wechat', 'alipay'], array_column($channels['rgtx']['payout_methods'], 'value'));
  94. self::assertSame([true, false, true], array_column($channels['rgtx']['payout_methods'], 'requires_card_no'));
  95. self::assertSame('Bank name', $channels['rgtx']['payout_methods'][0]['bank_name_label']);
  96. self::assertArrayNotHasKey('bank_name_label', $channels['rgtx']['payout_methods'][1]);
  97. $lottery = $this->unwrap($this->sendEncrypted('/api/newPc/yuanTou', 'GET', (object) []));
  98. self::assertSame(0, $lottery['code'] ?? null, json_encode($lottery));
  99. self::assertSame([], $lottery['data']);
  100. DB::table('users')->where('member_id', '42')->update(['real_name' => 'Fixture Name']);
  101. $response = $this->sendEncrypted('/api/wallet/payout?amount=900', 'POST', [
  102. 'channel' => 'NOwithdraw', 'amount' => 100, 'card_no' => ' no_fixture ', 'safe_word' => 'fixture-password',
  103. ], true, ['card_no' => 'outer-account']);
  104. $body = $this->unwrap($response);
  105. self::assertSame(0, $body['code'] ?? null, json_encode($body));
  106. $order = DB::table('payment_orders')->sole();
  107. self::assertSame('NOwithdraw', $order->channel);
  108. self::assertSame('no_fixture', $order->card_no);
  109. self::assertSame(100, (int) $order->amount);
  110. self::assertSame('Fixture Name', $order->account);
  111. DB::table('user_session')->where('token', self::TOKEN)->update(['expire_time' => time() - 1]);
  112. $expiredSession = $this->sendEncrypted('/api/wallet/withdrawChannel', 'GET', (object) [], true);
  113. self::assertSame(401, $expiredSession->getStatusCode());
  114. self::assertSame(401, $this->unwrap($expiredSession)['code']);
  115. }
  116. public function test_authentication_and_maintenance_errors_remain_encrypted(): void
  117. {
  118. $this->createMaintenanceFixture();
  119. $response = $this->sendEncrypted('/api/wallet/withdrawChannel', 'GET', (object) []);
  120. self::assertSame(401, $response->getStatusCode());
  121. self::assertSame(401, $this->unwrap($response)['code']);
  122. DB::table('maintenance_settings')->where('scope_code', 'site')->update(['enabled' => 1, 'platforms' => '["H5"]']);
  123. $response = $this->sendEncrypted('/api/wallet/withdrawChannel?platform=PC', 'GET', ['platform' => 'H5']);
  124. self::assertSame(200, $response->getStatusCode());
  125. self::assertSame(11001, $this->unwrap($response)['code']);
  126. }
  127. public function test_registered_real_name_is_used_for_binding_and_payout(): void
  128. {
  129. $this->createWalletFixture();
  130. DB::table('users')->where('member_id', '42')->update(['real_name' => 'Registered Name']);
  131. $binding = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', [
  132. 'channel' => 'DF001', 'bank_name' => 'Fixture Bank', 'bank_branch' => 'Fixture Branch',
  133. 'card_no' => '12345', 'account' => 'Different Name',
  134. ], true));
  135. self::assertSame('Registered Name', $binding['data']['real_name']);
  136. self::assertSame('Registered Name', DB::table('banks')->sole()->account);
  137. $payout = $this->unwrap($this->sendEncrypted('/api/wallet/payout', 'POST', [
  138. 'channel' => 'DF001', 'bank_name' => 'Fixture Bank', 'bank_branch' => 'Fixture Branch', 'card_no' => '12345',
  139. 'amount' => 100, 'safe_word' => 'fixture-password', 'account' => 'Another Name',
  140. ], true));
  141. self::assertSame(0, $payout['code'], $payout['msg']);
  142. self::assertSame('Registered Name', DB::table('payment_orders')->sole()->account);
  143. self::assertSame('Fixture Branch', DB::table('payment_orders')->sole()->bank_branch);
  144. $zimu = $this->unwrap($this->sendEncrypted('/api/wallet/payout', 'POST', [
  145. 'channel' => 'ZIMUwithdraw', 'card_no' => 'zimu-wallet-fixture',
  146. 'amount' => 100, 'safe_word' => 'fixture-password', 'account' => 'Another Name',
  147. ], true));
  148. self::assertSame(0, $zimu['code'], $zimu['msg']);
  149. self::assertSame('Registered Name', DB::table('payment_orders')->where('channel', 'ZIMUwithdraw')->value('account'));
  150. self::assertSame('Registered Name', UserService::paginate()['data']->first()->toArray()['real_name']);
  151. self::assertArrayNotHasKey('real_name', \App\Models\User::first()->toArray());
  152. }
  153. public function test_only_the_first_successful_named_binding_can_set_a_missing_real_name(): void
  154. {
  155. $this->createWalletFixture();
  156. $params = ['channel' => 'DF001', 'bank_name' => 'Fixture Bank', 'bank_branch' => 'Fixture Branch', 'card_no' => '12345'];
  157. $payout = $this->unwrap($this->sendEncrypted('/api/wallet/payout', 'POST', $params + [
  158. 'amount' => 100, 'safe_word' => 'fixture-password', 'account' => 'Unbound Name',
  159. ], true));
  160. self::assertSame('请先绑定收款账户并填写真实姓名', $payout['msg']);
  161. self::assertSame(0, DB::table('payment_orders')->count());
  162. $invalid = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', $params + [
  163. 'id' => 999, 'account' => 'Invalid Binding',
  164. ], true));
  165. self::assertNotSame(0, $invalid['code']);
  166. self::assertNull(DB::table('users')->value('real_name'));
  167. DB::unprepared("CREATE TRIGGER reject_bank BEFORE INSERT ON bot_banks
  168. WHEN NEW.card_no = 'reject-fixture' BEGIN SELECT RAISE(ABORT, 'fixture failure'); END");
  169. $failedSave = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', [
  170. 'card_no' => 'reject-fixture', 'account' => 'Failed Name',
  171. ] + $params, true));
  172. self::assertNotSame(0, $failedSave['code']);
  173. self::assertNull(DB::table('users')->value('real_name'));
  174. foreach (['NOwithdraw', 'ZIMUwithdraw'] as $channel) {
  175. $noName = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', [
  176. 'channel' => $channel, 'card_no' => 'wallet-fixture',
  177. ], true));
  178. self::assertSame('请输入真实姓名', $noName['msg']);
  179. $payout = $this->unwrap($this->sendEncrypted('/api/wallet/payout', 'POST', [
  180. 'channel' => $channel, 'card_no' => 'wallet-fixture', 'amount' => 100,
  181. 'safe_word' => 'fixture-password', 'account' => 'Unbound Name',
  182. ], true));
  183. self::assertSame('请先绑定收款账户并填写真实姓名', $payout['msg']);
  184. }
  185. $binding = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', $params + [
  186. 'account' => " First Bound Name ",
  187. ], true));
  188. self::assertSame('First Bound Name', $binding['data']['real_name']);
  189. $id = DB::table('banks')->where('channel', 'DF001')->value('id');
  190. $update = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', $params + [
  191. 'id' => $id, 'account' => 'Replacement Name',
  192. ], true));
  193. self::assertSame('First Bound Name', $update['data']['real_name']);
  194. self::assertSame('First Bound Name', DB::table('banks')->where('id', $id)->value('account'));
  195. $noBinding = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', [
  196. 'channel' => 'NOwithdraw', 'card_no' => 'no_fixture',
  197. ], true));
  198. self::assertSame(0, $noBinding['code']);
  199. $deleted = $this->unwrap($this->sendEncrypted('/api/wallet/delBank', 'GET', ['id' => $id], true));
  200. self::assertSame(0, $deleted['code']);
  201. $list = $this->unwrap($this->sendEncrypted('/api/wallet/bankList', 'GET', (object) [], true));
  202. self::assertSame('First Bound Name', $list['data']['real_name']);
  203. self::assertSame('First Bound Name', $list['data']['list'][0]['account']);
  204. }
  205. public function test_manual_payout_uses_bank_details_or_wallet_payment_qr_and_shares_the_pending_limit(): void
  206. {
  207. $this->createWalletFixture();
  208. $common = ['channel' => 'rgtx', 'amount' => 100, 'safe_word' => 'fixture-password'];
  209. $payout = fn (array $params) => $this->unwrap($this->sendEncrypted('/api/wallet/payout', 'POST', $params, true));
  210. $qr = 'https://uploads.example.test/fixture.png';
  211. $alipayAccount = 'cash@example.test';
  212. $wechat = ['payout_method' => 'wechat', 'qr_image' => $qr];
  213. $alipay = ['payout_method' => 'alipay', 'card_no' => $alipayAccount, 'qr_image' => $qr];
  214. foreach ([$wechat, $alipay] as $wallet) {
  215. self::assertSame('请先绑定收款账户并填写真实姓名', $payout($common + $wallet + ['account' => 'Injected Name'])['msg']);
  216. }
  217. DB::table('users')->update(['real_name' => 'Fixture Name']);
  218. self::assertSame('请输入银行名称', $payout($common + ['card_no' => '12345'])['msg']);
  219. self::assertSame('请输入银行名称', $payout(['channel' => 'DF001', 'card_no' => '12345'] + $common)['msg']);
  220. foreach (['rgtx', 'DF001'] as $channel) {
  221. self::assertSame('请输入开户行支行名称', $payout([
  222. 'channel' => $channel, 'bank_name' => 'Fixture Bank', 'card_no' => '12345',
  223. ] + $common)['msg']);
  224. }
  225. self::assertSame('请上传有效的收款码图片', $payout($common + ['payout_method' => 'wechat', 'qr_image' => 'javascript:alert(1)'])['msg']);
  226. self::assertSame('请上传有效的收款码图片', $payout($common + ['payout_method' => 'alipay', 'card_no' => $alipayAccount])['msg']);
  227. self::assertSame('请输入支付宝账号', $payout($common + ['payout_method' => 'alipay', 'qr_image' => $qr])['msg']);
  228. self::assertSame('请输入支付宝账号', $payout($common + ['card_no' => $qr] + $alipay)['msg']);
  229. $bank = $payout($common + ['bank_name' => 'Fixture Bank', 'bank_branch' => 'Fixture Branch', 'card_no' => '12345']);
  230. self::assertSame(0, $bank['code'], $bank['msg']);
  231. self::assertSame('bank', $bank['data']['payout_method']);
  232. self::assertSame('Fixture Bank', $bank['data']['bank_name']);
  233. self::assertSame('Fixture Branch', $bank['data']['bank_branch']);
  234. self::assertSame('您已有1笔提现订单正常处理中!', $payout($common + $wechat)['msg']);
  235. self::assertSame(1, DB::table('payment_orders')->count());
  236. self::assertSame(900, (int) DB::table('wallets')->value('available_balance'));
  237. foreach ([[$wechat, '微信', ''], [$alipay, '支付宝', $alipayAccount]] as [$wallet, $bankName, $cardNo]) {
  238. DB::table('payment_orders')->update(['status' => 2]);
  239. $result = $payout($common + $wallet + ['account' => 'Injected Name']);
  240. self::assertSame(0, $result['code'], $result['msg']);
  241. self::assertSame($wallet['payout_method'], $result['data']['payout_method']);
  242. self::assertSame($qr, $result['data']['qr_image']);
  243. $stored = DB::table('payment_orders')->where('id', $result['data']['id'])->first();
  244. self::assertSame(4, (int) $stored->type);
  245. self::assertSame([0.0, 0.0], [(float) $stored->fee, (float) $stored->fee_rate], '人工提现暂不记手续费');
  246. self::assertSame($bankName, $stored->bank_name);
  247. self::assertSame('Fixture Name', $stored->account);
  248. self::assertSame($cardNo, $stored->card_no);
  249. self::assertSame($qr, $stored->qr_image);
  250. }
  251. // Legacy WeChat rows stored the QR URL in the account column.
  252. DB::table('payment_orders')->where('bank_name', '微信')->update(['card_no' => $qr, 'qr_image' => null]);
  253. $this->app->instance('request', Request::create('/admin/withdraw/rmb?type=4'));
  254. $admin = (new \App\Http\Controllers\admin\Withdraw())->rmb()->getData(true);
  255. self::assertSame(0, $admin['code'], $admin['msg']);
  256. $rows = array_column($admin['data']['data'], null, 'payout_method');
  257. self::assertSame('Fixture Bank', $rows['bank']['bank_name']);
  258. self::assertSame('Fixture Branch', $rows['bank']['bank_branch']);
  259. self::assertSame('Fixture Name', $rows['wechat']['recipient_name']);
  260. self::assertSame('fixture-login', $rows['wechat']['account']);
  261. self::assertSame($qr, $rows['wechat']['qr_image']);
  262. self::assertSame('', $rows['wechat']['card_no']);
  263. self::assertSame($alipayAccount, $rows['alipay']['card_no']);
  264. self::assertSame($qr, $rows['alipay']['qr_image']);
  265. $bind = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', ['channel' => 'rgtx'] + $alipay, true));
  266. self::assertSame(0, $bind['code'], $bind['msg']);
  267. $bankList = $this->unwrap($this->sendEncrypted('/api/wallet/bankList', 'GET', ['channel' => 'rgtx'], true));
  268. self::assertSame($alipayAccount, $bankList['data']['list'][0]['card_no']);
  269. self::assertSame($qr, $bankList['data']['list'][0]['qr_image']);
  270. self::assertSame('Fixture Name', $bankList['data']['list'][0]['account']);
  271. }
  272. public function test_protocol_errors_and_rollout_flags(): void
  273. {
  274. Route::get('api/wallet/probe', fn () => response()->json(['code' => 0]));
  275. foreach (['/api/wallet/probe', '/api/newPc', '/api/issue', '/api/pcissue'] as $path) {
  276. $response = $this->send($path);
  277. self::assertSame(400, $response->getStatusCode());
  278. self::assertSame(4001, $this->unwrap($response)['code']);
  279. }
  280. $payload = $this->crypto->encrypt(['amount' => 100], 'request');
  281. $envelope = json_decode(base64_decode(strtr($payload, '-_', '+/')), true);
  282. $envelope['tag'][0] = $envelope['tag'][0] === 'A' ? 'B' : 'A';
  283. $tampered = rtrim(strtr(base64_encode(json_encode($envelope)), '+/', '-_'), '=');
  284. $expired = $this->crypto->encrypt((object) [], 'request', (time() - 301) * 1000);
  285. $wrongDirection = $this->crypto->encrypt((object) [], 'response');
  286. foreach (['', $tampered, $expired, $wrongDirection, $this->crypto->encrypt([], 'request')] as $invalid) {
  287. $response = $this->send('/api/wallet/probe?payload=' . $invalid, 'GET', ['HTTP_X_API_ENCRYPTED' => '1']);
  288. self::assertSame(400, $response->getStatusCode());
  289. self::assertSame(4002, $this->unwrap($response)['code']);
  290. }
  291. Route::get('api/wallet/large-response', fn () => response()->json(['data' => str_repeat('x', 1024)]));
  292. config(['api_crypto.max_payload_bytes' => 1024]);
  293. $response = $this->sendEncrypted('/api/wallet/large-response', 'GET', (object) []);
  294. self::assertSame(500, $response->getStatusCode());
  295. self::assertSame(5002, json_decode($response->getContent(), true)['code']);
  296. config(['api_crypto.allow_plaintext' => true]);
  297. self::assertSame('{"code":0}', $this->send('/api/wallet/probe')->getContent());
  298. self::assertSame(0, $this->unwrap($this->sendEncrypted('/api/wallet/probe', 'GET', (object) []))['code']);
  299. config(['api_crypto.key' => 'invalid']);
  300. $response = $this->send('/api/wallet/probe');
  301. self::assertSame(500, $response->getStatusCode());
  302. self::assertSame(5001, json_decode($response->getContent(), true)['code']);
  303. config(['api_crypto.enabled' => false]);
  304. self::assertSame('{"code":0}', $this->send('/api/wallet/probe')->getContent());
  305. }
  306. public function test_allowlisted_user_can_send_plaintext_and_receives_plaintext_for_encrypted_requests(): void
  307. {
  308. $this->createWalletFixture();
  309. DB::table('users')->update(['user_id' => '9000000001', 'real_name' => 'Fixture Name']);
  310. DB::table('user_session')->update(['user_id' => '9000000001']);
  311. config(['api_crypto.plaintext_user_ids' => ['99', '9000000001']]);
  312. $headers = ['HTTP_AUTHORIZATION' => self::TOKEN];
  313. $response = $this->send('/api/wallet/withdrawChannel?lang=en', 'GET', $headers);
  314. self::assertSame(200, $response->getStatusCode());
  315. self::assertSame('0', $response->headers->get('X-Api-Encrypted'));
  316. self::assertStringContainsString('no-store', $response->headers->get('Cache-Control'));
  317. self::assertStringContainsString('X-Api-Encrypted', $response->headers->get('Access-Control-Expose-Headers'));
  318. $body = json_decode($response->getContent(), true);
  319. self::assertSame('Bank card', array_column($body['data'], 'label', 'value')['DF001']);
  320. Schema::create('pc_issues', function (Blueprint $table) {
  321. $table->id();
  322. $table->integer('status');
  323. $table->string('issue_no');
  324. });
  325. $response = $this->send('/api/newPc/yuanTou', 'GET', $headers);
  326. self::assertSame('0', $response->headers->get('X-Api-Encrypted'));
  327. self::assertSame(0, json_decode($response->getContent(), true)['code']);
  328. $response = $this->sendEncrypted('/api/wallet/addBank', 'POST', [
  329. 'channel' => 'DF001', 'bank_name' => 'Fixture Bank', 'bank_branch' => 'Fixture Branch', 'card_no' => '12345',
  330. ], true);
  331. self::assertSame('0', $response->headers->get('X-Api-Encrypted'));
  332. self::assertSame(0, json_decode($response->getContent(), true)['code']);
  333. self::assertSame('12345', DB::table('banks')->sole()->card_no);
  334. $response = $this->send('/api/wallet/addBank', 'POST', $headers + ['HTTP_X_API_ENCRYPTED' => '1']);
  335. self::assertSame(400, $response->getStatusCode());
  336. self::assertSame('0', $response->headers->get('X-Api-Encrypted'));
  337. self::assertSame(4002, json_decode($response->getContent(), true)['code']);
  338. }
  339. public function test_allowlist_requires_the_matching_users_active_token(): void
  340. {
  341. $this->createWalletFixture();
  342. DB::table('users')->update(['user_id' => '99']);
  343. DB::table('user_session')->update(['user_id' => '99']);
  344. $url = '/api/wallet/withdrawChannel?user_id=42';
  345. $headers = ['HTTP_AUTHORIZATION' => self::TOKEN];
  346. config(['api_crypto.plaintext_user_ids' => ['42']]);
  347. // 42 is the member_id and the submitted parameter, but the token belongs to user_id 99.
  348. self::assertSame(4001, $this->unwrap($this->send($url, 'GET', $headers))['code']);
  349. self::assertSame(0, $this->unwrap($this->sendEncrypted($url, 'GET', (object) [], true))['code']);
  350. config(['api_crypto.plaintext_user_ids' => ['99']]);
  351. self::assertSame(4001, $this->unwrap($this->send($url))['code']);
  352. self::assertSame(4001, $this->unwrap($this->send($url, 'GET', ['HTTP_AUTHORIZATION' => 'invalid-token']))['code']);
  353. DB::table('users')->update(['is_banned' => 1]);
  354. self::assertSame(4001, $this->unwrap($this->send($url, 'GET', $headers))['code']);
  355. DB::table('users')->update(['is_banned' => 0]);
  356. DB::table('user_session')->update(['create_time' => time() - 86401, 'update_time' => 0]);
  357. self::assertSame(4001, $this->unwrap($this->send($url, 'GET', $headers))['code']);
  358. }
  359. public function test_withdrawal_account_and_address_apis_ignore_and_hide_aliases(): void
  360. {
  361. $this->createWalletFixture();
  362. $bank = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', [
  363. 'channel' => 'DF001', 'bank_name' => 'Fixture Bank', 'bank_branch' => 'Fixture Branch',
  364. 'card_no' => '12345', 'account' => 'Fixture Name',
  365. 'alias' => 'ignored bank alias',
  366. ], true));
  367. self::assertSame(0, $bank['code']);
  368. self::assertSame('', DB::table('banks')->value('alias'));
  369. DB::table('banks')->update(['alias' => 'old bank alias']);
  370. $bankList = $this->unwrap($this->sendEncrypted('/api/wallet/bankList', 'GET', (object) [], true));
  371. self::assertSame('12345', $bankList['data']['list'][0]['card_no']);
  372. self::assertSame('Fixture Bank', $bankList['data']['list'][0]['bank_name']);
  373. self::assertSame('Fixture Branch', $bankList['data']['list'][0]['bank_branch']);
  374. self::assertArrayNotHasKey('alias', $bankList['data']['list'][0]);
  375. DB::table('banks')->update(['bank_branch' => null, 'alias' => 'Legacy Branch']);
  376. $bankList = $this->unwrap($this->sendEncrypted('/api/wallet/bankList', 'GET', (object) [], true));
  377. self::assertSame('Legacy Branch', $bankList['data']['list'][0]['bank_branch']);
  378. self::assertArrayNotHasKey('alias', $bankList['data']['list'][0]);
  379. $address = 'T' . str_repeat('A', 33);
  380. $saved = $this->unwrap($this->sendEncrypted('/api/wallet/addAddress', 'POST', [
  381. 'address' => $address, 'alias' => 'ignored address alias',
  382. ], true));
  383. self::assertSame(0, $saved['code']);
  384. self::assertNull(DB::table('address')->value('alias'));
  385. DB::table('address')->update(['alias' => 'old address alias']);
  386. $addresses = $this->unwrap($this->sendEncrypted('/api/wallet/address', 'GET', (object) [], true));
  387. self::assertSame($address, $addresses['data']['list'][0]['address']);
  388. self::assertArrayNotHasKey('alias', $addresses['data']['list'][0]);
  389. }
  390. public function test_telegram_saves_withdrawal_destinations_without_prompting_for_aliases(): void
  391. {
  392. $this->createWalletFixture();
  393. Cache::put('42_QB_WITHDRAW_CHANNEL', 'DF001');
  394. QianBaoWithdrawService::onMessage(42, 'Fixture Bank', 1, StepStatus::QB_INPUT_BANK_NAME);
  395. self::assertSame(StepStatus::QB_INPUT_BANK_BRANCH, Cache::get(get_step_key(42)));
  396. QianBaoWithdrawService::onMessage(42, 'Fixture Branch', 1, StepStatus::QB_INPUT_BANK_BRANCH);
  397. self::assertSame(StepStatus::QB_INPUT_CARD_NO, Cache::get(get_step_key(42)));
  398. QianBaoWithdrawService::onMessage(42, '12345', 1, StepStatus::QB_INPUT_CARD_NO);
  399. self::assertSame(StepStatus::QB_INPUT_ACCOUNT, Cache::get(get_step_key(42)));
  400. $saved = QianBaoWithdrawService::onMessage(42, 'Fixture Name', 2, StepStatus::QB_INPUT_ACCOUNT);
  401. $keyboard = json_decode($saved['reply_markup'], true)['inline_keyboard'];
  402. self::assertSame('12345', $keyboard[0][0]['text']);
  403. self::assertSame('', DB::table('banks')->where('card_no', '12345')->value('alias'));
  404. self::assertSame('Fixture Branch', DB::table('banks')->where('card_no', '12345')->value('bank_branch'));
  405. self::assertNull(Cache::get(get_step_key(42)));
  406. Cache::put('42_QB_WITHDRAW_CARD_NO', '67890');
  407. Cache::put('42_QB_ACCOUNT', 'Fixture Name');
  408. QianBaoWithdrawService::onMessage(42, 'Legacy Branch', 3, StepStatus::LEGACY_QB_INPUT_ALIAS);
  409. self::assertSame('', DB::table('banks')->where('card_no', '67890')->value('alias'));
  410. self::assertSame('Legacy Branch', DB::table('banks')->where('card_no', '67890')->value('bank_branch'));
  411. $address = 'T' . str_repeat('A', 33);
  412. $saved = WithdrawService::onMessage(42, $address, 4, StepStatus::INPUT_ADDRESS_TRC20);
  413. $keyboard = json_decode($saved['reply_markup'], true)['inline_keyboard'];
  414. self::assertSame($address, $keyboard[0][0]['text']);
  415. self::assertNull(Cache::get(get_step_key(42)));
  416. $oldAddress = 'T' . str_repeat('B', 33);
  417. Cache::put('42_address', $oldAddress);
  418. WithdrawService::onMessage(42, 'ignored', 5, StepStatus::LEGACY_INPUT_ADDRESS_ALIAS);
  419. self::assertTrue(DB::table('address')->where('address', $oldAddress)->exists());
  420. self::assertSame(0, DB::table('address')->whereNotNull('alias')->count());
  421. }
  422. public function test_jd_and_usdt_require_a_saved_name_without_storing_it_on_orders(): void
  423. {
  424. $this->createWalletFixture();
  425. $address = 'T' . str_repeat('A', 33);
  426. $jd = ['channel' => 'JDpay', 'card_no' => 'jd-wallet', 'amount' => 100, 'safe_word' => 'fixture-password'];
  427. $usdt = ['address' => $address, 'amount' => 20, 'safe_word' => 'fixture-password'];
  428. $post = fn (string $route, array $params) => $this->unwrap(
  429. $this->sendEncrypted('/api/wallet/' . $route, 'POST', $params, true)
  430. );
  431. self::assertSame('请先绑定收款账户并填写真实姓名', $post('payout', $jd + ['account' => 'Injected Name'])['msg']);
  432. self::assertSame('请先绑定收款账户并填写真实姓名', $post('withdraw', $usdt + ['account' => 'Injected Name'])['msg']);
  433. self::assertSame('请输入真实姓名', $post('addBank', ['channel' => 'JDpay', 'card_no' => 'jd-wallet'])['msg']);
  434. self::assertSame('请输入真实姓名', $post('addAddress', ['address' => $address])['msg']);
  435. self::assertNull(DB::table('users')->value('real_name'));
  436. $binding = $post('addAddress', ['address' => $address, 'account' => 'Fixture Name']);
  437. self::assertSame(0, $binding['code']);
  438. self::assertSame('Fixture Name', $binding['data']['real_name']);
  439. $jdBinding = $post('addBank', ['channel' => 'JDpay', 'card_no' => 'jd-wallet', 'account' => 'Ignored Name']);
  440. self::assertSame(0, $jdBinding['code']);
  441. self::assertSame('', DB::table('banks')->sole()->account);
  442. $jdResult = $post('payout', $jd);
  443. self::assertSame(0, $jdResult['code'], $jdResult['msg']);
  444. self::assertSame('', DB::table('payment_orders')->sole()->account);
  445. $usdtResult = $post('withdraw', $usdt);
  446. self::assertSame(0, $usdtResult['code'], $usdtResult['msg']);
  447. self::assertArrayNotHasKey('account', $usdtResult['data']);
  448. self::assertSame('Fixture Name', DB::table('users')->value('real_name'));
  449. }
  450. public function test_cors_preflight_and_head(): void
  451. {
  452. Route::get('api/wallet/probe', fn () => response()->json(['code' => 0]));
  453. $response = $this->send('/api/wallet/probe', 'OPTIONS', [
  454. 'HTTP_ORIGIN' => 'https://frontend.example',
  455. 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST',
  456. 'HTTP_ACCESS_CONTROL_REQUEST_HEADERS' => 'content-type,x-api-encrypted',
  457. ]);
  458. self::assertSame(204, $response->getStatusCode());
  459. self::assertStringContainsString('x-api-encrypted', strtolower($response->headers->get('Access-Control-Allow-Headers')));
  460. self::assertFalse($response->headers->has('X-Api-Encrypted'));
  461. $response = $this->sendEncrypted('/api/wallet/probe', 'HEAD', (object) []);
  462. self::assertSame(200, $response->getStatusCode());
  463. self::assertSame('1', $response->headers->get('X-Api-Encrypted'));
  464. self::assertStringContainsString('X-Api-Encrypted', $response->headers->get('Access-Control-Expose-Headers'));
  465. self::assertSame('', $response->getContent());
  466. }
  467. public function test_excluded_routes_keep_plaintext_input_and_response(): void
  468. {
  469. $this->createMaintenanceFixture();
  470. (require dirname(__DIR__, 2) . '/database/migrations/2026_09_08_160000_add_payment_provider_status_and_channel_soft_deletes.php')->up();
  471. $this->app->instance(TelegramWebHook::class, new class extends TelegramWebHook {
  472. public function __construct() {}
  473. public function handle(Request $request): JsonResponse
  474. {
  475. return response()->json(['update_id' => $request->input('update_id')]);
  476. }
  477. });
  478. $telegram = $this->send('/api/onMessage', 'POST', ['CONTENT_TYPE' => 'application/json'], '{"update_id":123}');
  479. self::assertSame('{"update_id":123}', $telegram->getContent());
  480. self::assertFalse($telegram->headers->has('X-Api-Encrypted'));
  481. foreach (['notify', 'harvest'] as $callback) {
  482. $response = $this->send('/api/pay/' . $callback, 'POST', ['CONTENT_TYPE' => 'application/json'],
  483. '{"appId":"unmatched-fixture","merchantNum":"unmatched-fixture","userCode":"unmatched-fixture","mchId":"unmatched-fixture"}');
  484. self::assertSame(200, $response->getStatusCode(), $callback . ': ' . $response->getContent());
  485. self::assertSame('success', $response->getContent());
  486. self::assertFalse($response->headers->has('X-Api-Encrypted'));
  487. }
  488. Schema::create('config', function (Blueprint $table) {
  489. $table->id();
  490. $table->string('field');
  491. $table->string('val');
  492. });
  493. DB::table('config')->insert([
  494. ['field' => 'activity_rule', 'val' => 'Telegram rule fixture'],
  495. ['field' => 'benefits_service_customer', 'val' => 'customer fixture'],
  496. ]);
  497. $activity = $this->send('/api/ActivityReward/rule');
  498. self::assertSame(200, $activity->getStatusCode());
  499. self::assertSame('Telegram rule fixture', json_decode($activity->getContent(), true)['data']['rule']);
  500. self::assertFalse($activity->headers->has('X-Api-Encrypted'));
  501. $admin = $this->send('/admin/maintenance/options');
  502. self::assertFalse($admin->headers->has('X-Api-Encrypted'));
  503. }
  504. private function send(string $url, string $method = 'GET', array $headers = [], ?string $body = null): Response
  505. {
  506. return $this->kernel->handle(Request::create($url, $method, [], [], [], $headers + [
  507. 'HTTP_ORIGIN' => 'https://frontend.example',
  508. 'HTTP_ACCEPT' => 'application/json',
  509. ], $body));
  510. }
  511. private function sendEncrypted(string $url, string $method, array|object $parameters, bool $authenticated = false, array $outerBody = []): Response
  512. {
  513. $payload = $this->crypto->encrypt($parameters, 'request');
  514. $headers = ['HTTP_X_API_ENCRYPTED' => '1'];
  515. if ($authenticated) {
  516. $headers['HTTP_AUTHORIZATION'] = self::TOKEN;
  517. }
  518. if ($method === 'POST') {
  519. return $this->send($url, $method, $headers + ['CONTENT_TYPE' => 'application/json'], json_encode(['payload' => $payload] + $outerBody));
  520. }
  521. return $this->send($url . (str_contains($url, '?') ? '&' : '?') . 'payload=' . $payload, $method, $headers);
  522. }
  523. private function unwrap(Response $response): array
  524. {
  525. self::assertSame('1', $response->headers->get('X-Api-Encrypted'), (string) $response->getContent());
  526. self::assertStringContainsString('no-store', $response->headers->get('Cache-Control'));
  527. $envelope = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
  528. self::assertTrue($envelope['encrypted']);
  529. return $this->crypto->decrypt($envelope['payload'], 'response');
  530. }
  531. private function createMaintenanceFixture(): void
  532. {
  533. (require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_120000_create_maintenance_settings.php')->up();
  534. }
  535. private function createWalletFixture(): void
  536. {
  537. DB::connection()->getPdo()->sqliteCreateCollation('utf8mb4_general_ci', 'strcasecmp');
  538. $this->createMaintenanceFixture();
  539. Schema::create('users', function (Blueprint $table) {
  540. $table->id();
  541. $table->string('user_id');
  542. $table->string('member_id');
  543. $table->integer('is_banned')->default(0);
  544. $table->string('account')->default('fixture-login');
  545. $table->string('first_name')->nullable();
  546. $table->string('admin_note')->nullable();
  547. $table->integer('from');
  548. $table->bigInteger('last_active_time')->default(0);
  549. $table->integer('recharge_channel_group_id');
  550. $table->string('payment_password');
  551. $table->integer('level')->default(1);
  552. $table->string('agent_user_code')->nullable();
  553. $table->timestamps();
  554. });
  555. (require dirname(__DIR__, 2) . '/database/migrations/2026_11_06_120000_add_real_name_to_users.php')->up();
  556. Schema::create('level', function (Blueprint $table) {
  557. $table->id();
  558. $table->integer('level');
  559. $table->string('level_name');
  560. $table->string('img');
  561. $table->decimal('recharge');
  562. });
  563. Schema::create('user_session', function (Blueprint $table) {
  564. $table->id();
  565. $table->string('user_id');
  566. $table->string('token');
  567. $table->bigInteger('expire_time');
  568. $table->bigInteger('create_time');
  569. $table->bigInteger('update_time');
  570. });
  571. Schema::create('recharge_channel_group', function (Blueprint $table) {
  572. $table->id();
  573. $table->string('withdraw_type');
  574. $table->softDeletes();
  575. });
  576. Schema::create('recharge_channel', function (Blueprint $table) {
  577. $table->id();
  578. $table->integer('data_type');
  579. $table->integer('status');
  580. $table->integer('sort');
  581. $table->string('name');
  582. $table->string('type');
  583. $table->decimal('rate');
  584. $table->decimal('min');
  585. $table->decimal('max');
  586. $table->string('fixed')->nullable();
  587. $table->string('logo')->nullable();
  588. });
  589. (require dirname(__DIR__, 2) . '/database/migrations/2026_09_08_160000_add_payment_provider_status_and_channel_soft_deletes.php')->up();
  590. Schema::create('wallets', function (Blueprint $table) {
  591. $table->id();
  592. $table->string('member_id');
  593. $table->decimal('available_balance');
  594. $table->decimal('frozen_balance')->default(0);
  595. $table->timestamps();
  596. });
  597. Schema::create('payment_orders', function (Blueprint $table) {
  598. $table->id();
  599. $table->integer('type');
  600. $table->integer('status');
  601. foreach (['member_id', 'order_no', 'channel', 'bank_name', 'account', 'card_no'] as $field) {
  602. $table->string($field);
  603. }
  604. $table->string('callback_url')->nullable();
  605. $table->string('remark')->nullable();
  606. $table->decimal('amount');
  607. $table->decimal('fee');
  608. $table->decimal('fee_rate', 10, 6)->default(0);
  609. $table->decimal('fee_fixed', 18, 4)->default(0);
  610. $table->timestamps();
  611. });
  612. Schema::create('backflow', function (Blueprint $table) {
  613. $table->id();
  614. $table->string('member_id');
  615. $table->string('date');
  616. $table->integer('status')->default(0);
  617. $table->decimal('withdrawal_amount')->default(0);
  618. $table->timestamps();
  619. });
  620. Schema::create('balance_logs', function (Blueprint $table) {
  621. $table->id();
  622. $table->integer('type')->default(1);
  623. $table->string('member_id');
  624. $table->string('change_type');
  625. $table->integer('related_id');
  626. $table->string('remark');
  627. $table->decimal('amount');
  628. $table->decimal('before_balance');
  629. $table->decimal('after_balance');
  630. $table->timestamps();
  631. });
  632. Schema::create('banks', function (Blueprint $table) {
  633. $table->id();
  634. foreach (['member_id', 'channel', 'bank_name', 'account', 'card_no'] as $field) {
  635. $table->string($field);
  636. }
  637. $table->string('alias')->default('');
  638. $table->timestamps();
  639. });
  640. Schema::create('address', function (Blueprint $table) {
  641. $table->id();
  642. $table->string('member_id');
  643. $table->string('address');
  644. $table->string('alias')->nullable();
  645. $table->timestamps();
  646. });
  647. (require dirname(__DIR__, 2) . '/database/migrations/2026_11_06_140000_add_qr_image_to_withdrawal_destinations.php')->up();
  648. (require dirname(__DIR__, 2) . '/database/migrations/2026_11_06_150000_add_bank_branch_to_withdrawal_destinations.php')->up();
  649. Schema::create('config', function (Blueprint $table) {
  650. $table->id();
  651. $table->string('field');
  652. $table->string('val');
  653. });
  654. DB::table('config')->insert([['field' => 'service_charge', 'val' => '1'], ['field' => 'exchange_rate_rmb', 'val' => '1']]);
  655. Schema::create('withdraws', function (Blueprint $table) {
  656. $table->id();
  657. $table->string('member_id');
  658. $table->string('address');
  659. $table->integer('status');
  660. foreach (['amount', 'service_charge', 'to_account', 'exchange_rate', 'after_balance'] as $field) {
  661. $table->decimal($field);
  662. }
  663. $table->timestamps();
  664. });
  665. DB::table('users')->insert(['user_id' => '42', 'member_id' => '42', 'from' => 1, 'recharge_channel_group_id' => 1,
  666. 'payment_password' => password_hash('fixture-password', PASSWORD_DEFAULT)]);
  667. DB::table('user_session')->insert(['user_id' => '42', 'token' => self::TOKEN,
  668. 'expire_time' => time() + 3600, 'create_time' => time(), 'update_time' => time()]);
  669. DB::table('wallets')->insert(['member_id' => '42', 'available_balance' => 1000]);
  670. DB::table('recharge_channel_group')->insert(['id' => 1, 'withdraw_type' => 'DF001,NOwithdraw,ZIMUwithdraw,JDpay,rgtx,usdt']);
  671. foreach (['DF001' => '银行卡', 'NOwithdraw' => 'NO快捷提现', 'ZIMUwithdraw' => '808账户提现', 'JDpay' => 'JD钱包', 'rgtx' => '人工提现', 'usdt' => 'USDT提现'] as $type => $name) {
  672. DB::table('recharge_channel')->insert(['data_type' => 2, 'status' => 1, 'sort' => 0,
  673. 'name' => $name, 'type' => $type, 'rate' => 0, 'min' => 10, 'max' => 5000]);
  674. }
  675. }
  676. }