errorReporting = error_reporting(); $this->displayErrors = ini_get('display_errors'); $this->app = require dirname(__DIR__, 2) . '/bootstrap/app.php'; $this->kernel = $this->app->make(Kernel::class); $this->kernel->bootstrap(); $key = base64_encode(str_repeat('k', 32)); $this->crypto = new ApiCryptoService($key); config([ 'app.env' => 'testing', 'app.debug' => false, 'app.key' => 'base64:' . $key, 'api_crypto.enabled' => true, 'api_crypto.key' => $key, 'api_crypto.allow_plaintext' => false, 'api_crypto.plaintext_user_ids' => [], 'api_crypto.timestamp_tolerance' => 300, 'api_crypto.max_payload_bytes' => 2097152, 'cache.default' => 'array', 'database.default' => 'sqlite', 'database.connections.sqlite.url' => null, 'database.connections.sqlite.database' => ':memory:', 'database.connections.sqlite.prefix' => 'bot_', 'logging.default' => 'null', 'logging.channels.dblog' => config('logging.channels.null'), 'app.tree_payment_merchant_id' => '', 'app.tree_pay_mch_id' => '', 'app.jd_pay_mch_id' => '', ]); } protected function tearDown(): void { DB::disconnect(); Facade::clearResolvedInstances(); $this->app->flush(); restore_error_handler(); restore_exception_handler(); error_reporting($this->errorReporting); ini_set('display_errors', $this->displayErrors); parent::tearDown(); } public function test_real_wallet_and_lottery_routes_keep_the_original_business_response(): void { $this->createWalletFixture(); Schema::create('pc_issues', function (Blueprint $table) { $table->id(); $table->integer('status'); $table->string('issue_no'); }); $response = $this->sendEncrypted('/api/wallet/withdrawChannel?lang=zh', 'GET', ['lang' => 'en'], true); $body = $this->unwrap($response); self::assertSame(0, $body['code']); self::assertSame(['code', 'timestamp', 'msg', 'data'], array_keys($body)); self::assertSame('Bank card', array_column($body['data'], 'label', 'value')['DF001']); $channels = array_column($body['data'], null, 'value'); self::assertTrue($channels['DF001']['requires_bank_name']); self::assertTrue($channels['DF001']['requires_bank_branch']); self::assertSame('Bank name', $channels['DF001']['bank_name_label']); self::assertTrue($channels['NOwithdraw']['requires_real_name']); self::assertTrue($channels['ZIMUwithdraw']['requires_real_name']); self::assertTrue($channels['JDpay']['requires_real_name']); self::assertTrue($channels['usdt']['requires_real_name']); self::assertSame(['bank', 'wechat', 'alipay'], array_column($channels['rgtx']['payout_methods'], 'value')); self::assertSame([true, false, true], array_column($channels['rgtx']['payout_methods'], 'requires_card_no')); self::assertSame('Bank name', $channels['rgtx']['payout_methods'][0]['bank_name_label']); self::assertArrayNotHasKey('bank_name_label', $channels['rgtx']['payout_methods'][1]); $lottery = $this->unwrap($this->sendEncrypted('/api/newPc/yuanTou', 'GET', (object) [])); self::assertSame(0, $lottery['code'] ?? null, json_encode($lottery)); self::assertSame([], $lottery['data']); DB::table('users')->where('member_id', '42')->update(['real_name' => 'Fixture Name']); $response = $this->sendEncrypted('/api/wallet/payout?amount=900', 'POST', [ 'channel' => 'NOwithdraw', 'amount' => 100, 'card_no' => ' no_fixture ', 'safe_word' => 'fixture-password', ], true, ['card_no' => 'outer-account']); $body = $this->unwrap($response); self::assertSame(0, $body['code'] ?? null, json_encode($body)); $order = DB::table('payment_orders')->sole(); self::assertSame('NOwithdraw', $order->channel); self::assertSame('no_fixture', $order->card_no); self::assertSame(100, (int) $order->amount); self::assertSame('Fixture Name', $order->account); DB::table('user_session')->where('token', self::TOKEN)->update(['expire_time' => time() - 1]); $expiredSession = $this->sendEncrypted('/api/wallet/withdrawChannel', 'GET', (object) [], true); self::assertSame(401, $expiredSession->getStatusCode()); self::assertSame(401, $this->unwrap($expiredSession)['code']); } public function test_authentication_and_maintenance_errors_remain_encrypted(): void { $this->createMaintenanceFixture(); $response = $this->sendEncrypted('/api/wallet/withdrawChannel', 'GET', (object) []); self::assertSame(401, $response->getStatusCode()); self::assertSame(401, $this->unwrap($response)['code']); DB::table('maintenance_settings')->where('scope_code', 'site')->update(['enabled' => 1, 'platforms' => '["H5"]']); $response = $this->sendEncrypted('/api/wallet/withdrawChannel?platform=PC', 'GET', ['platform' => 'H5']); self::assertSame(200, $response->getStatusCode()); self::assertSame(11001, $this->unwrap($response)['code']); } public function test_registered_real_name_is_used_for_binding_and_payout(): void { $this->createWalletFixture(); DB::table('users')->where('member_id', '42')->update(['real_name' => 'Registered Name']); $binding = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', [ 'channel' => 'DF001', 'bank_name' => 'Fixture Bank', 'bank_branch' => 'Fixture Branch', 'card_no' => '12345', 'account' => 'Different Name', ], true)); self::assertSame('Registered Name', $binding['data']['real_name']); self::assertSame('Registered Name', DB::table('banks')->sole()->account); $payout = $this->unwrap($this->sendEncrypted('/api/wallet/payout', 'POST', [ 'channel' => 'DF001', 'bank_name' => 'Fixture Bank', 'bank_branch' => 'Fixture Branch', 'card_no' => '12345', 'amount' => 100, 'safe_word' => 'fixture-password', 'account' => 'Another Name', ], true)); self::assertSame(0, $payout['code'], $payout['msg']); self::assertSame('Registered Name', DB::table('payment_orders')->sole()->account); self::assertSame('Fixture Branch', DB::table('payment_orders')->sole()->bank_branch); $zimu = $this->unwrap($this->sendEncrypted('/api/wallet/payout', 'POST', [ 'channel' => 'ZIMUwithdraw', 'card_no' => 'zimu-wallet-fixture', 'amount' => 100, 'safe_word' => 'fixture-password', 'account' => 'Another Name', ], true)); self::assertSame(0, $zimu['code'], $zimu['msg']); self::assertSame('Registered Name', DB::table('payment_orders')->where('channel', 'ZIMUwithdraw')->value('account')); self::assertSame('Registered Name', UserService::paginate()['data']->first()->toArray()['real_name']); self::assertArrayNotHasKey('real_name', \App\Models\User::first()->toArray()); } public function test_only_the_first_successful_named_binding_can_set_a_missing_real_name(): void { $this->createWalletFixture(); $params = ['channel' => 'DF001', 'bank_name' => 'Fixture Bank', 'bank_branch' => 'Fixture Branch', 'card_no' => '12345']; $payout = $this->unwrap($this->sendEncrypted('/api/wallet/payout', 'POST', $params + [ 'amount' => 100, 'safe_word' => 'fixture-password', 'account' => 'Unbound Name', ], true)); self::assertSame('请先绑定收款账户并填写真实姓名', $payout['msg']); self::assertSame(0, DB::table('payment_orders')->count()); $invalid = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', $params + [ 'id' => 999, 'account' => 'Invalid Binding', ], true)); self::assertNotSame(0, $invalid['code']); self::assertNull(DB::table('users')->value('real_name')); DB::unprepared("CREATE TRIGGER reject_bank BEFORE INSERT ON bot_banks WHEN NEW.card_no = 'reject-fixture' BEGIN SELECT RAISE(ABORT, 'fixture failure'); END"); $failedSave = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', [ 'card_no' => 'reject-fixture', 'account' => 'Failed Name', ] + $params, true)); self::assertNotSame(0, $failedSave['code']); self::assertNull(DB::table('users')->value('real_name')); foreach (['NOwithdraw', 'ZIMUwithdraw'] as $channel) { $noName = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', [ 'channel' => $channel, 'card_no' => 'wallet-fixture', ], true)); self::assertSame('请输入真实姓名', $noName['msg']); $payout = $this->unwrap($this->sendEncrypted('/api/wallet/payout', 'POST', [ 'channel' => $channel, 'card_no' => 'wallet-fixture', 'amount' => 100, 'safe_word' => 'fixture-password', 'account' => 'Unbound Name', ], true)); self::assertSame('请先绑定收款账户并填写真实姓名', $payout['msg']); } $binding = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', $params + [ 'account' => " First Bound Name ", ], true)); self::assertSame('First Bound Name', $binding['data']['real_name']); $id = DB::table('banks')->where('channel', 'DF001')->value('id'); $update = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', $params + [ 'id' => $id, 'account' => 'Replacement Name', ], true)); self::assertSame('First Bound Name', $update['data']['real_name']); self::assertSame('First Bound Name', DB::table('banks')->where('id', $id)->value('account')); $noBinding = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', [ 'channel' => 'NOwithdraw', 'card_no' => 'no_fixture', ], true)); self::assertSame(0, $noBinding['code']); $deleted = $this->unwrap($this->sendEncrypted('/api/wallet/delBank', 'GET', ['id' => $id], true)); self::assertSame(0, $deleted['code']); $list = $this->unwrap($this->sendEncrypted('/api/wallet/bankList', 'GET', (object) [], true)); self::assertSame('First Bound Name', $list['data']['real_name']); self::assertSame('First Bound Name', $list['data']['list'][0]['account']); } public function test_manual_payout_uses_bank_details_or_wallet_payment_qr_and_shares_the_pending_limit(): void { $this->createWalletFixture(); $common = ['channel' => 'rgtx', 'amount' => 100, 'safe_word' => 'fixture-password']; $payout = fn (array $params) => $this->unwrap($this->sendEncrypted('/api/wallet/payout', 'POST', $params, true)); $qr = 'https://uploads.example.test/fixture.png'; $alipayAccount = 'cash@example.test'; $wechat = ['payout_method' => 'wechat', 'qr_image' => $qr]; $alipay = ['payout_method' => 'alipay', 'card_no' => $alipayAccount, 'qr_image' => $qr]; foreach ([$wechat, $alipay] as $wallet) { self::assertSame('请先绑定收款账户并填写真实姓名', $payout($common + $wallet + ['account' => 'Injected Name'])['msg']); } DB::table('users')->update(['real_name' => 'Fixture Name']); self::assertSame('请输入银行名称', $payout($common + ['card_no' => '12345'])['msg']); self::assertSame('请输入银行名称', $payout(['channel' => 'DF001', 'card_no' => '12345'] + $common)['msg']); foreach (['rgtx', 'DF001'] as $channel) { self::assertSame('请输入开户行支行名称', $payout([ 'channel' => $channel, 'bank_name' => 'Fixture Bank', 'card_no' => '12345', ] + $common)['msg']); } self::assertSame('请上传有效的收款码图片', $payout($common + ['payout_method' => 'wechat', 'qr_image' => 'javascript:alert(1)'])['msg']); self::assertSame('请上传有效的收款码图片', $payout($common + ['payout_method' => 'alipay', 'card_no' => $alipayAccount])['msg']); self::assertSame('请输入支付宝账号', $payout($common + ['payout_method' => 'alipay', 'qr_image' => $qr])['msg']); self::assertSame('请输入支付宝账号', $payout($common + ['card_no' => $qr] + $alipay)['msg']); $bank = $payout($common + ['bank_name' => 'Fixture Bank', 'bank_branch' => 'Fixture Branch', 'card_no' => '12345']); self::assertSame(0, $bank['code'], $bank['msg']); self::assertSame('bank', $bank['data']['payout_method']); self::assertSame('Fixture Bank', $bank['data']['bank_name']); self::assertSame('Fixture Branch', $bank['data']['bank_branch']); self::assertSame('您已有1笔提现订单正常处理中!', $payout($common + $wechat)['msg']); self::assertSame(1, DB::table('payment_orders')->count()); self::assertSame(900, (int) DB::table('wallets')->value('available_balance')); foreach ([[$wechat, '微信', ''], [$alipay, '支付宝', $alipayAccount]] as [$wallet, $bankName, $cardNo]) { DB::table('payment_orders')->update(['status' => 2]); $result = $payout($common + $wallet + ['account' => 'Injected Name']); self::assertSame(0, $result['code'], $result['msg']); self::assertSame($wallet['payout_method'], $result['data']['payout_method']); self::assertSame($qr, $result['data']['qr_image']); $stored = DB::table('payment_orders')->where('id', $result['data']['id'])->first(); self::assertSame(4, (int) $stored->type); self::assertSame($bankName, $stored->bank_name); self::assertSame('Fixture Name', $stored->account); self::assertSame($cardNo, $stored->card_no); self::assertSame($qr, $stored->qr_image); } // Legacy WeChat rows stored the QR URL in the account column. DB::table('payment_orders')->where('bank_name', '微信')->update(['card_no' => $qr, 'qr_image' => null]); $this->app->instance('request', Request::create('/admin/withdraw/rmb?type=4')); $admin = (new \App\Http\Controllers\admin\Withdraw())->rmb()->getData(true); self::assertSame(0, $admin['code'], $admin['msg']); $rows = array_column($admin['data']['data'], null, 'payout_method'); self::assertSame('Fixture Bank', $rows['bank']['bank_name']); self::assertSame('Fixture Branch', $rows['bank']['bank_branch']); self::assertSame('Fixture Name', $rows['wechat']['recipient_name']); self::assertSame('fixture-login', $rows['wechat']['account']); self::assertSame($qr, $rows['wechat']['qr_image']); self::assertSame('', $rows['wechat']['card_no']); self::assertSame($alipayAccount, $rows['alipay']['card_no']); self::assertSame($qr, $rows['alipay']['qr_image']); $bind = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', ['channel' => 'rgtx'] + $alipay, true)); self::assertSame(0, $bind['code'], $bind['msg']); $bankList = $this->unwrap($this->sendEncrypted('/api/wallet/bankList', 'GET', ['channel' => 'rgtx'], true)); self::assertSame($alipayAccount, $bankList['data']['list'][0]['card_no']); self::assertSame($qr, $bankList['data']['list'][0]['qr_image']); self::assertSame('Fixture Name', $bankList['data']['list'][0]['account']); } public function test_protocol_errors_and_rollout_flags(): void { Route::get('api/wallet/probe', fn () => response()->json(['code' => 0])); foreach (['/api/wallet/probe', '/api/newPc', '/api/issue', '/api/pcissue'] as $path) { $response = $this->send($path); self::assertSame(400, $response->getStatusCode()); self::assertSame(4001, $this->unwrap($response)['code']); } $payload = $this->crypto->encrypt(['amount' => 100], 'request'); $envelope = json_decode(base64_decode(strtr($payload, '-_', '+/')), true); $envelope['tag'][0] = $envelope['tag'][0] === 'A' ? 'B' : 'A'; $tampered = rtrim(strtr(base64_encode(json_encode($envelope)), '+/', '-_'), '='); $expired = $this->crypto->encrypt((object) [], 'request', (time() - 301) * 1000); $wrongDirection = $this->crypto->encrypt((object) [], 'response'); foreach (['', $tampered, $expired, $wrongDirection, $this->crypto->encrypt([], 'request')] as $invalid) { $response = $this->send('/api/wallet/probe?payload=' . $invalid, 'GET', ['HTTP_X_API_ENCRYPTED' => '1']); self::assertSame(400, $response->getStatusCode()); self::assertSame(4002, $this->unwrap($response)['code']); } Route::get('api/wallet/large-response', fn () => response()->json(['data' => str_repeat('x', 1024)])); config(['api_crypto.max_payload_bytes' => 1024]); $response = $this->sendEncrypted('/api/wallet/large-response', 'GET', (object) []); self::assertSame(500, $response->getStatusCode()); self::assertSame(5002, json_decode($response->getContent(), true)['code']); config(['api_crypto.allow_plaintext' => true]); self::assertSame('{"code":0}', $this->send('/api/wallet/probe')->getContent()); self::assertSame(0, $this->unwrap($this->sendEncrypted('/api/wallet/probe', 'GET', (object) []))['code']); config(['api_crypto.key' => 'invalid']); $response = $this->send('/api/wallet/probe'); self::assertSame(500, $response->getStatusCode()); self::assertSame(5001, json_decode($response->getContent(), true)['code']); config(['api_crypto.enabled' => false]); self::assertSame('{"code":0}', $this->send('/api/wallet/probe')->getContent()); } public function test_allowlisted_user_can_send_plaintext_and_receives_plaintext_for_encrypted_requests(): void { $this->createWalletFixture(); DB::table('users')->update(['user_id' => '9000000001', 'real_name' => 'Fixture Name']); DB::table('user_session')->update(['user_id' => '9000000001']); config(['api_crypto.plaintext_user_ids' => ['99', '9000000001']]); $headers = ['HTTP_AUTHORIZATION' => self::TOKEN]; $response = $this->send('/api/wallet/withdrawChannel?lang=en', 'GET', $headers); self::assertSame(200, $response->getStatusCode()); self::assertSame('0', $response->headers->get('X-Api-Encrypted')); self::assertStringContainsString('no-store', $response->headers->get('Cache-Control')); self::assertStringContainsString('X-Api-Encrypted', $response->headers->get('Access-Control-Expose-Headers')); $body = json_decode($response->getContent(), true); self::assertSame('Bank card', array_column($body['data'], 'label', 'value')['DF001']); Schema::create('pc_issues', function (Blueprint $table) { $table->id(); $table->integer('status'); $table->string('issue_no'); }); $response = $this->send('/api/newPc/yuanTou', 'GET', $headers); self::assertSame('0', $response->headers->get('X-Api-Encrypted')); self::assertSame(0, json_decode($response->getContent(), true)['code']); $response = $this->sendEncrypted('/api/wallet/addBank', 'POST', [ 'channel' => 'DF001', 'bank_name' => 'Fixture Bank', 'bank_branch' => 'Fixture Branch', 'card_no' => '12345', ], true); self::assertSame('0', $response->headers->get('X-Api-Encrypted')); self::assertSame(0, json_decode($response->getContent(), true)['code']); self::assertSame('12345', DB::table('banks')->sole()->card_no); $response = $this->send('/api/wallet/addBank', 'POST', $headers + ['HTTP_X_API_ENCRYPTED' => '1']); self::assertSame(400, $response->getStatusCode()); self::assertSame('0', $response->headers->get('X-Api-Encrypted')); self::assertSame(4002, json_decode($response->getContent(), true)['code']); } public function test_allowlist_requires_the_matching_users_active_token(): void { $this->createWalletFixture(); DB::table('users')->update(['user_id' => '99']); DB::table('user_session')->update(['user_id' => '99']); $url = '/api/wallet/withdrawChannel?user_id=42'; $headers = ['HTTP_AUTHORIZATION' => self::TOKEN]; config(['api_crypto.plaintext_user_ids' => ['42']]); // 42 is the member_id and the submitted parameter, but the token belongs to user_id 99. self::assertSame(4001, $this->unwrap($this->send($url, 'GET', $headers))['code']); self::assertSame(0, $this->unwrap($this->sendEncrypted($url, 'GET', (object) [], true))['code']); config(['api_crypto.plaintext_user_ids' => ['99']]); self::assertSame(4001, $this->unwrap($this->send($url))['code']); self::assertSame(4001, $this->unwrap($this->send($url, 'GET', ['HTTP_AUTHORIZATION' => 'invalid-token']))['code']); DB::table('users')->update(['is_banned' => 1]); self::assertSame(4001, $this->unwrap($this->send($url, 'GET', $headers))['code']); DB::table('users')->update(['is_banned' => 0]); DB::table('user_session')->update(['create_time' => time() - 86401, 'update_time' => 0]); self::assertSame(4001, $this->unwrap($this->send($url, 'GET', $headers))['code']); } public function test_withdrawal_account_and_address_apis_ignore_and_hide_aliases(): void { $this->createWalletFixture(); $bank = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', [ 'channel' => 'DF001', 'bank_name' => 'Fixture Bank', 'bank_branch' => 'Fixture Branch', 'card_no' => '12345', 'account' => 'Fixture Name', 'alias' => 'ignored bank alias', ], true)); self::assertSame(0, $bank['code']); self::assertSame('', DB::table('banks')->value('alias')); DB::table('banks')->update(['alias' => 'old bank alias']); $bankList = $this->unwrap($this->sendEncrypted('/api/wallet/bankList', 'GET', (object) [], true)); self::assertSame('12345', $bankList['data']['list'][0]['card_no']); self::assertSame('Fixture Bank', $bankList['data']['list'][0]['bank_name']); self::assertSame('Fixture Branch', $bankList['data']['list'][0]['bank_branch']); self::assertArrayNotHasKey('alias', $bankList['data']['list'][0]); DB::table('banks')->update(['bank_branch' => null, 'alias' => 'Legacy Branch']); $bankList = $this->unwrap($this->sendEncrypted('/api/wallet/bankList', 'GET', (object) [], true)); self::assertSame('Legacy Branch', $bankList['data']['list'][0]['bank_branch']); self::assertArrayNotHasKey('alias', $bankList['data']['list'][0]); $address = 'T' . str_repeat('A', 33); $saved = $this->unwrap($this->sendEncrypted('/api/wallet/addAddress', 'POST', [ 'address' => $address, 'alias' => 'ignored address alias', ], true)); self::assertSame(0, $saved['code']); self::assertNull(DB::table('address')->value('alias')); DB::table('address')->update(['alias' => 'old address alias']); $addresses = $this->unwrap($this->sendEncrypted('/api/wallet/address', 'GET', (object) [], true)); self::assertSame($address, $addresses['data']['list'][0]['address']); self::assertArrayNotHasKey('alias', $addresses['data']['list'][0]); } public function test_telegram_saves_withdrawal_destinations_without_prompting_for_aliases(): void { $this->createWalletFixture(); Cache::put('42_QB_WITHDRAW_CHANNEL', 'DF001'); QianBaoWithdrawService::onMessage(42, 'Fixture Bank', 1, StepStatus::QB_INPUT_BANK_NAME); self::assertSame(StepStatus::QB_INPUT_BANK_BRANCH, Cache::get(get_step_key(42))); QianBaoWithdrawService::onMessage(42, 'Fixture Branch', 1, StepStatus::QB_INPUT_BANK_BRANCH); self::assertSame(StepStatus::QB_INPUT_CARD_NO, Cache::get(get_step_key(42))); QianBaoWithdrawService::onMessage(42, '12345', 1, StepStatus::QB_INPUT_CARD_NO); self::assertSame(StepStatus::QB_INPUT_ACCOUNT, Cache::get(get_step_key(42))); $saved = QianBaoWithdrawService::onMessage(42, 'Fixture Name', 2, StepStatus::QB_INPUT_ACCOUNT); $keyboard = json_decode($saved['reply_markup'], true)['inline_keyboard']; self::assertSame('12345', $keyboard[0][0]['text']); self::assertSame('', DB::table('banks')->where('card_no', '12345')->value('alias')); self::assertSame('Fixture Branch', DB::table('banks')->where('card_no', '12345')->value('bank_branch')); self::assertNull(Cache::get(get_step_key(42))); Cache::put('42_QB_WITHDRAW_CARD_NO', '67890'); Cache::put('42_QB_ACCOUNT', 'Fixture Name'); QianBaoWithdrawService::onMessage(42, 'Legacy Branch', 3, StepStatus::LEGACY_QB_INPUT_ALIAS); self::assertSame('', DB::table('banks')->where('card_no', '67890')->value('alias')); self::assertSame('Legacy Branch', DB::table('banks')->where('card_no', '67890')->value('bank_branch')); $address = 'T' . str_repeat('A', 33); $saved = WithdrawService::onMessage(42, $address, 4, StepStatus::INPUT_ADDRESS_TRC20); $keyboard = json_decode($saved['reply_markup'], true)['inline_keyboard']; self::assertSame($address, $keyboard[0][0]['text']); self::assertNull(Cache::get(get_step_key(42))); $oldAddress = 'T' . str_repeat('B', 33); Cache::put('42_address', $oldAddress); WithdrawService::onMessage(42, 'ignored', 5, StepStatus::LEGACY_INPUT_ADDRESS_ALIAS); self::assertTrue(DB::table('address')->where('address', $oldAddress)->exists()); self::assertSame(0, DB::table('address')->whereNotNull('alias')->count()); } public function test_jd_and_usdt_require_a_saved_name_without_storing_it_on_orders(): void { $this->createWalletFixture(); $address = 'T' . str_repeat('A', 33); $jd = ['channel' => 'JDpay', 'card_no' => 'jd-wallet', 'amount' => 100, 'safe_word' => 'fixture-password']; $usdt = ['address' => $address, 'amount' => 20, 'safe_word' => 'fixture-password']; $post = fn (string $route, array $params) => $this->unwrap( $this->sendEncrypted('/api/wallet/' . $route, 'POST', $params, true) ); self::assertSame('请先绑定收款账户并填写真实姓名', $post('payout', $jd + ['account' => 'Injected Name'])['msg']); self::assertSame('请先绑定收款账户并填写真实姓名', $post('withdraw', $usdt + ['account' => 'Injected Name'])['msg']); self::assertSame('请输入真实姓名', $post('addBank', ['channel' => 'JDpay', 'card_no' => 'jd-wallet'])['msg']); self::assertSame('请输入真实姓名', $post('addAddress', ['address' => $address])['msg']); self::assertNull(DB::table('users')->value('real_name')); $binding = $post('addAddress', ['address' => $address, 'account' => 'Fixture Name']); self::assertSame(0, $binding['code']); self::assertSame('Fixture Name', $binding['data']['real_name']); $jdBinding = $post('addBank', ['channel' => 'JDpay', 'card_no' => 'jd-wallet', 'account' => 'Ignored Name']); self::assertSame(0, $jdBinding['code']); self::assertSame('', DB::table('banks')->sole()->account); $jdResult = $post('payout', $jd); self::assertSame(0, $jdResult['code'], $jdResult['msg']); self::assertSame('', DB::table('payment_orders')->sole()->account); $usdtResult = $post('withdraw', $usdt); self::assertSame(0, $usdtResult['code'], $usdtResult['msg']); self::assertArrayNotHasKey('account', $usdtResult['data']); self::assertSame('Fixture Name', DB::table('users')->value('real_name')); } public function test_cors_preflight_and_head(): void { Route::get('api/wallet/probe', fn () => response()->json(['code' => 0])); $response = $this->send('/api/wallet/probe', 'OPTIONS', [ 'HTTP_ORIGIN' => 'https://frontend.example', 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST', 'HTTP_ACCESS_CONTROL_REQUEST_HEADERS' => 'content-type,x-api-encrypted', ]); self::assertSame(204, $response->getStatusCode()); self::assertStringContainsString('x-api-encrypted', strtolower($response->headers->get('Access-Control-Allow-Headers'))); self::assertFalse($response->headers->has('X-Api-Encrypted')); $response = $this->sendEncrypted('/api/wallet/probe', 'HEAD', (object) []); self::assertSame(200, $response->getStatusCode()); self::assertSame('1', $response->headers->get('X-Api-Encrypted')); self::assertStringContainsString('X-Api-Encrypted', $response->headers->get('Access-Control-Expose-Headers')); self::assertSame('', $response->getContent()); } public function test_excluded_routes_keep_plaintext_input_and_response(): void { $this->createMaintenanceFixture(); (require dirname(__DIR__, 2) . '/database/migrations/2026_09_08_160000_add_payment_provider_status_and_channel_soft_deletes.php')->up(); $this->app->instance(TelegramWebHook::class, new class extends TelegramWebHook { public function __construct() {} public function handle(Request $request): JsonResponse { return response()->json(['update_id' => $request->input('update_id')]); } }); $telegram = $this->send('/api/onMessage', 'POST', ['CONTENT_TYPE' => 'application/json'], '{"update_id":123}'); self::assertSame('{"update_id":123}', $telegram->getContent()); self::assertFalse($telegram->headers->has('X-Api-Encrypted')); foreach (['notify', 'harvest'] as $callback) { $response = $this->send('/api/pay/' . $callback, 'POST', ['CONTENT_TYPE' => 'application/json'], '{"appId":"unmatched-fixture","merchantNum":"unmatched-fixture","userCode":"unmatched-fixture","mchId":"unmatched-fixture"}'); self::assertSame(200, $response->getStatusCode(), $callback . ': ' . $response->getContent()); self::assertSame('success', $response->getContent()); self::assertFalse($response->headers->has('X-Api-Encrypted')); } Schema::create('config', function (Blueprint $table) { $table->id(); $table->string('field'); $table->string('val'); }); DB::table('config')->insert([ ['field' => 'activity_rule', 'val' => 'Telegram rule fixture'], ['field' => 'benefits_service_customer', 'val' => 'customer fixture'], ]); $activity = $this->send('/api/ActivityReward/rule'); self::assertSame(200, $activity->getStatusCode()); self::assertSame('Telegram rule fixture', json_decode($activity->getContent(), true)['data']['rule']); self::assertFalse($activity->headers->has('X-Api-Encrypted')); $admin = $this->send('/admin/maintenance/options'); self::assertFalse($admin->headers->has('X-Api-Encrypted')); } private function send(string $url, string $method = 'GET', array $headers = [], ?string $body = null): Response { return $this->kernel->handle(Request::create($url, $method, [], [], [], $headers + [ 'HTTP_ORIGIN' => 'https://frontend.example', 'HTTP_ACCEPT' => 'application/json', ], $body)); } private function sendEncrypted(string $url, string $method, array|object $parameters, bool $authenticated = false, array $outerBody = []): Response { $payload = $this->crypto->encrypt($parameters, 'request'); $headers = ['HTTP_X_API_ENCRYPTED' => '1']; if ($authenticated) { $headers['HTTP_AUTHORIZATION'] = self::TOKEN; } if ($method === 'POST') { return $this->send($url, $method, $headers + ['CONTENT_TYPE' => 'application/json'], json_encode(['payload' => $payload] + $outerBody)); } return $this->send($url . (str_contains($url, '?') ? '&' : '?') . 'payload=' . $payload, $method, $headers); } private function unwrap(Response $response): array { self::assertSame('1', $response->headers->get('X-Api-Encrypted'), (string) $response->getContent()); self::assertStringContainsString('no-store', $response->headers->get('Cache-Control')); $envelope = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); self::assertTrue($envelope['encrypted']); return $this->crypto->decrypt($envelope['payload'], 'response'); } private function createMaintenanceFixture(): void { (require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_120000_create_maintenance_settings.php')->up(); } private function createWalletFixture(): void { DB::connection()->getPdo()->sqliteCreateCollation('utf8mb4_general_ci', 'strcasecmp'); $this->createMaintenanceFixture(); Schema::create('users', function (Blueprint $table) { $table->id(); $table->string('user_id'); $table->string('member_id'); $table->integer('is_banned')->default(0); $table->string('account')->default('fixture-login'); $table->string('first_name')->nullable(); $table->string('admin_note')->nullable(); $table->integer('from'); $table->bigInteger('last_active_time')->default(0); $table->integer('recharge_channel_group_id'); $table->string('payment_password'); $table->integer('level')->default(1); $table->string('agent_user_code')->nullable(); $table->timestamps(); }); (require dirname(__DIR__, 2) . '/database/migrations/2026_11_06_120000_add_real_name_to_users.php')->up(); Schema::create('level', function (Blueprint $table) { $table->id(); $table->integer('level'); $table->string('level_name'); $table->string('img'); $table->decimal('recharge'); }); Schema::create('user_session', function (Blueprint $table) { $table->id(); $table->string('user_id'); $table->string('token'); $table->bigInteger('expire_time'); $table->bigInteger('create_time'); $table->bigInteger('update_time'); }); Schema::create('recharge_channel_group', function (Blueprint $table) { $table->id(); $table->string('withdraw_type'); $table->softDeletes(); }); Schema::create('recharge_channel', function (Blueprint $table) { $table->id(); $table->integer('data_type'); $table->integer('status'); $table->integer('sort'); $table->string('name'); $table->string('type'); $table->decimal('rate'); $table->decimal('min'); $table->decimal('max'); $table->string('fixed')->nullable(); $table->string('logo')->nullable(); }); (require dirname(__DIR__, 2) . '/database/migrations/2026_09_08_160000_add_payment_provider_status_and_channel_soft_deletes.php')->up(); Schema::create('wallets', function (Blueprint $table) { $table->id(); $table->string('member_id'); $table->decimal('available_balance'); $table->decimal('frozen_balance')->default(0); $table->timestamps(); }); Schema::create('payment_orders', function (Blueprint $table) { $table->id(); $table->integer('type'); $table->integer('status'); foreach (['member_id', 'order_no', 'channel', 'bank_name', 'account', 'card_no'] as $field) { $table->string($field); } $table->string('callback_url')->nullable(); $table->string('remark')->nullable(); $table->decimal('amount'); $table->decimal('fee'); $table->timestamps(); }); Schema::create('backflow', function (Blueprint $table) { $table->id(); $table->string('member_id'); $table->string('date'); $table->integer('status')->default(0); $table->decimal('withdrawal_amount')->default(0); $table->timestamps(); }); Schema::create('balance_logs', function (Blueprint $table) { $table->id(); $table->integer('type')->default(1); $table->string('member_id'); $table->string('change_type'); $table->integer('related_id'); $table->string('remark'); $table->decimal('amount'); $table->decimal('before_balance'); $table->decimal('after_balance'); $table->timestamps(); }); Schema::create('banks', function (Blueprint $table) { $table->id(); foreach (['member_id', 'channel', 'bank_name', 'account', 'card_no'] as $field) { $table->string($field); } $table->string('alias')->default(''); $table->timestamps(); }); Schema::create('address', function (Blueprint $table) { $table->id(); $table->string('member_id'); $table->string('address'); $table->string('alias')->nullable(); $table->timestamps(); }); (require dirname(__DIR__, 2) . '/database/migrations/2026_11_06_140000_add_qr_image_to_withdrawal_destinations.php')->up(); (require dirname(__DIR__, 2) . '/database/migrations/2026_11_06_150000_add_bank_branch_to_withdrawal_destinations.php')->up(); Schema::create('config', function (Blueprint $table) { $table->id(); $table->string('field'); $table->string('val'); }); DB::table('config')->insert([['field' => 'service_charge', 'val' => '1'], ['field' => 'exchange_rate_rmb', 'val' => '1']]); Schema::create('withdraws', function (Blueprint $table) { $table->id(); $table->string('member_id'); $table->string('address'); $table->integer('status'); foreach (['amount', 'service_charge', 'to_account', 'exchange_rate', 'after_balance'] as $field) { $table->decimal($field); } $table->timestamps(); }); DB::table('users')->insert(['user_id' => '42', 'member_id' => '42', 'from' => 1, 'recharge_channel_group_id' => 1, 'payment_password' => password_hash('fixture-password', PASSWORD_DEFAULT)]); DB::table('user_session')->insert(['user_id' => '42', 'token' => self::TOKEN, 'expire_time' => time() + 3600, 'create_time' => time(), 'update_time' => time()]); DB::table('wallets')->insert(['member_id' => '42', 'available_balance' => 1000]); DB::table('recharge_channel_group')->insert(['id' => 1, 'withdraw_type' => 'DF001,NOwithdraw,ZIMUwithdraw,JDpay,rgtx,usdt']); foreach (['DF001' => '银行卡', 'NOwithdraw' => 'NO快捷提现', 'ZIMUwithdraw' => '808账户提现', 'JDpay' => 'JD钱包', 'rgtx' => '人工提现', 'usdt' => 'USDT提现'] as $type => $name) { DB::table('recharge_channel')->insert(['data_type' => 2, 'status' => 1, 'sort' => 0, 'name' => $name, 'type' => $type, 'rate' => 0, 'min' => 10, 'max' => 5000]); } } }