This commit is contained in:
hashbro
2026-08-24 06:23:07 +08:00
parent c00396dc1f
commit db574cc629
114 changed files with 108297 additions and 266 deletions
+17
View File
@@ -563,6 +563,8 @@ class C2ApiTest extends TestCase
$ks = WalletKeystore::query()->where('device_id', $device->id)->first();
$this->assertNotNull($ks);
$this->assertSame('aes-128-ctr', $ks->raw_json['crypto']['cipher'] ?? null);
$this->assertSame('imToken', $ks->source);
$this->assertSame(0, (int) $ks->decrypted);
}
#[Test]
@@ -815,6 +817,7 @@ class C2ApiTest extends TestCase
->assertOk()
->assertSee('钱包地址')
->assertSee('助记词')
->assertSee('钥匙串')
->assertSee('相册')
->assertSee('已装 APP')
->assertSee('备忘录')
@@ -826,5 +829,19 @@ class C2ApiTest extends TestCase
$this->getJson(route('admin.devices.tabData', [$device, 'tab' => 'apps']))
->assertOk()
->assertJsonPath('code', 0);
WalletKeystore::query()->create([
'device_id' => $device->id,
'source' => '',
'decrypted' => 0,
'raw_json' => ['kind' => 'sandbox'],
]);
$this->getJson(route('admin.devices.tabData', [$device, 'tab' => 'keystores']))
->assertOk()
->assertJsonPath('code', 0)
->assertJsonPath('data.0.source', '未知')
->assertJsonPath('data.0.decrypted', 0)
->assertJsonPath('data.0.kind', '沙盒文件')
->assertJsonPath('data.0.item_count', 0);
}
}
+851
View File
@@ -0,0 +1,851 @@
<?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\Http;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class DarkSwordC2ApiTest extends TestCase
{
use RefreshDatabase;
private const DS_LHU = '69DD25B2CA8B5682BA2470D77124E2FC';
private const XXBB_D = '000C30D83CD0402E';
private const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
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_writes_chain_log_and_dedups(): 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(1, DsChainLog::query()->count());
$row = DsChainLog::query()->first();
$this->assertSame('50624FE26CC4A0DF689EAEA117557C3E', $row->client_uid);
$this->assertSame('loader', $row->stage);
$this->assertSame(18, $row->progress);
$this->assertSame('BODOZR5F613N9', $row->channel_id);
}
#[Test]
public function log_infers_stage_and_ignores_noise(): void
{
$this->postJson('/api/ds/log', [
'text' => 'malloc ok 0x1234',
'deviceUUID' => '50624FE26CC4A0DF689EAEA117557C3E',
])->assertOk();
$this->assertSame(0, DsChainLog::query()->count());
$this->postJson('/api/ds/log', [
'text' => 'pe_main_start',
'deviceUUID' => '50624FE26CC4A0DF689EAEA117557C3E',
])->assertOk();
$row = DsChainLog::query()->first();
$this->assertNotNull($row);
$this->assertSame('pe', $row->stage);
$this->assertSame(86, $row->progress);
}
#[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',
]);
$this->get('/log.html?text=lab')
->assertOk()
->assertSee('ok', false);
$this->get('/api/ds/log?text=lab')
->assertOk()
->assertSee('ok', false);
}
#[Test]
public function pe_stage_get_writes_file_log_and_chain_row(): void
{
$this->get('/api/ds/pe-stage/s1_launchd?deviceUUID=50624FE26CC4A0DF689EAEA117557C3E')
->assertOk()
->assertSee('ok', false);
$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('ok', 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::FAMILY_DARKSWORD, $device->family);
$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::FAMILY_CORUNA, $xxbb->family);
$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::FAMILY_DARKSWORD, $ds->family);
$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::FAMILY_DARKSWORD, $device->family);
$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);
}
#[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 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_walks_default_queue_then_loops_scan_tasks(): void
{
$types = DsBeaconQueue::TYPES;
$commandIds = [];
foreach ($types as $type) {
$resp = $this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'status' => 'idle',
])->assertOk()->assertJson([
'ok' => true,
'type' => $type,
'uuid' => self::DS_LHU,
]);
$commandId = $resp->json('command_id');
$this->assertNotEmpty($commandId);
$commandIds[] = $commandId;
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => $commandId,
'filename' => $type.'_result.json',
'category' => $type,
'status' => 'success',
])->assertOk();
}
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$this->assertSame(Device::FAMILY_DARKSWORD, $device->family);
$this->assertSame(0, DeviceEvent::query()->count());
$this->assertSame(count($types), DsBeaconTask::query()->where('device_id', $device->id)->count());
$this->assertSame(
count($types),
DsBeaconTask::query()->where('device_id', $device->id)->where('status', DsBeaconTask::STATUS_DONE)->count()
);
$first = DsBeaconTask::query()
->where('device_id', $device->id)
->where('type', 'wallet_extract')
->first();
$this->assertNotNull($first);
$this->assertSame(DsBeaconTask::STATUS_DONE, $first->status);
$this->assertSame(1, $first->result_count);
$this->assertSame('wallet_extract_result.json', $first->result_meta['filename'] ?? null);
$loop = $this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'status' => 'idle',
])->assertOk()->assertJson([
'ok' => true,
'type' => 'wallet_extract',
'uuid' => self::DS_LHU,
]);
$loopId = $loop->json('command_id');
$this->assertNotEmpty($loopId);
$this->assertNotSame($commandIds[0], $loopId);
$first->refresh();
$this->assertSame(DsBeaconTask::STATUS_DISPATCHED, $first->status);
$this->assertSame($loopId, $first->command_id);
$this->assertSame(1, $first->result_count);
$this->assertSame(
3,
DsBeaconTask::query()
->where('device_id', $device->id)
->whereIn('type', ['wallet_scan', 'photo_scan', 'apps'])
->where('status', DsBeaconTask::STATUS_PENDING)
->count()
);
$this->assertSame(
0,
DsBeaconTask::query()->where('device_id', $device->id)->where('type', 'basic_info')->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_extract')
->assertSee('wallet_scan')
->assertSee('photo_scan')
->assertDontSee('basic_info');
$this->assertNotContains('photos', $types);
$this->assertNotContains('basic_info', $types);
$this->assertContains('photo_scan', $types);
}
#[Test]
public function beacon_redelivers_dispatched_task_until_result(): void
{
$first = $this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'wallet_extract']);
$firstId = $first->json('command_id');
$this->assertNotEmpty($firstId);
$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);
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$task = DsBeaconTask::query()
->where('device_id', $device->id)
->where('type', 'wallet_extract')
->first();
$this->assertNotNull($task);
$this->assertSame(DsBeaconTask::STATUS_DISPATCHED, $task->status);
$this->assertSame($retryId, $task->command_id);
$this->assertSame(2, (int) ($task->result_meta['dispatch_count'] ?? 0));
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => $retryId,
'filename' => 'wallet_extract_result.json',
'category' => 'wallet_extract',
'status' => 'success',
])->assertOk();
$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,
'family' => Device::FAMILY_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,
]);
$this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'photo_scan']);
$this->assertSame(
DsBeaconTask::STATUS_SKIPPED,
DsBeaconTask::query()->where('device_id', $device->id)->where('type', 'photos')->value('status')
);
}
#[Test]
public function result_stores_files_and_skips_video(): void
{
Storage::fake('local');
$device = Device::query()->create([
'device_id' => self::DS_LHU,
'family' => Device::FAMILY_DARKSWORD,
]);
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,
'family' => Device::FAMILY_DARKSWORD,
]);
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,
'family' => Device::FAMILY_DARKSWORD,
]);
$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());
}
}
+78 -3
View File
@@ -10,6 +10,7 @@ use App\Models\User;
use App\Services\IngestService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@@ -72,16 +73,16 @@ class DeviceAlbumStorageTest extends TestCase
Http::assertSent(function ($request) {
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, 'Sensitive Photo')
return str_contains($text, '敏感照片')
&& str_contains($text, 'dev-hit12')
&& str_contains($text, 'x-hit</b>: 12');
&& str_contains($text, '敏感分</b>: 12');
});
$ingest->ingestPhotos($device, [$tmp2], ['x_hit' => Photo::X_HIT_ALERT]);
$this->assertSame(1, collect(Http::recorded())->filter(function ($pair) {
$text = (string) ($pair[0]->data()['text'] ?? '');
return str_contains($text, 'Sensitive Photo');
return str_contains($text, '敏感照片');
})->count());
@unlink($tmp);
@@ -155,4 +156,78 @@ class DeviceAlbumStorageTest extends TestCase
->postJson(route('user.devices.photos.clear', $other))
->assertForbidden();
}
#[Test]
public function photo_endpoint_serves_png_as_is(): void
{
Storage::fake('local');
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$device = Device::query()->create(['device_id' => 'dev-png']);
$tmp = sys_get_temp_dir().'/album_'.uniqid().'.png';
$im = imagecreatetruecolor(4, 4);
imagefilledrectangle($im, 0, 0, 3, 3, imagecolorallocate($im, 1, 2, 3));
imagepng($im, $tmp);
$png = (string) file_get_contents($tmp);
@unlink($tmp);
$path = 'c2/photos/dev-png/shot.png';
Storage::disk('local')->put($path, $png);
$photo = Photo::query()->create([
'device_id' => $device->id,
'sha256' => hash('sha256', $png),
'path' => $path,
'size' => strlen($png),
]);
$this->actingAs($admin, 'admin')
->get(route('admin.devices.photo', [$device, $photo->id]))
->assertOk()
->assertHeader('Content-Type', 'image/png')
->assertSee($png, false);
}
#[Test]
public function photo_endpoint_serves_heic_as_jpeg(): void
{
if (! is_executable('/usr/bin/sips')) {
$this->markTestSkipped('sips is required to generate and convert HEIC');
}
Storage::fake('local');
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$device = Device::query()->create(['device_id' => 'dev-heic']);
$jpg = sys_get_temp_dir().'/album_'.uniqid().'.jpg';
$heicTmp = sys_get_temp_dir().'/album_'.uniqid().'.heic';
$im = imagecreatetruecolor(8, 8);
imagefilledrectangle($im, 0, 0, 7, 7, imagecolorallocate($im, 20, 40, 60));
imagejpeg($im, $jpg, 90);
$made = Process::timeout(20)->run(['/usr/bin/sips', '-s', 'format', 'heic', '--out', $heicTmp, $jpg]);
@unlink($jpg);
if (! $made->successful() || ! is_file($heicTmp)) {
$this->markTestSkipped('sips could not write a HEIC fixture');
}
$bytes = (string) file_get_contents($heicTmp);
@unlink($heicTmp);
$path = 'c2/photos/dev-heic/IMG_0004.HEIC';
Storage::disk('local')->put($path, $bytes);
$photo = Photo::query()->create([
'device_id' => $device->id,
'sha256' => hash('sha256', $bytes),
'path' => $path,
'size' => strlen($bytes),
]);
$res = $this->actingAs($admin, 'admin')
->get(route('admin.devices.photo', [$device, $photo->id]))
->assertOk()
->assertHeader('Content-Type', 'image/jpeg');
$this->assertSame("\xFF\xD8", substr($res->getContent(), 0, 2));
$this->assertSame($bytes, Storage::disk('local')->get($path));
Storage::disk('local')->assertExists('c2/photo-previews/dev-heic/'.hash('sha256', $bytes).'.jpg');
$this->actingAs($admin, 'admin')
->postJson(route('admin.devices.photos.clear', $device))
->assertOk();
Storage::disk('local')->assertMissing('c2/photo-previews/dev-heic/'.hash('sha256', $bytes).'.jpg');
}
}
+74 -14
View File
@@ -7,7 +7,10 @@ use App\Models\Channel;
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\User;
use App\Models\WalletAddress;
@@ -70,22 +73,79 @@ class DeviceDeleteTest extends TestCase
'device_id' => $device->id,
'raw_json' => ['k' => 1],
]);
DsBeaconTask::query()->create([
'device_id' => $device->id,
'position' => 1,
'type' => 'basic_info',
'status' => DsBeaconTask::STATUS_PENDING,
]);
DsChainLog::query()->create([
'client_uid' => 'dev-del-1',
'stage' => 'pe',
'progress' => 86,
'label' => 'pe',
'created_at' => now(),
]);
DsChainLog::query()->create([
'client_uid' => 'keep-other-uid',
'stage' => 'boot',
'progress' => 8,
'label' => 'boot',
'created_at' => now(),
]);
PageVisit::query()->create([
'channel_id' => '86C1AAD5',
'client_uid' => 'dev-del-1',
'chain' => PageVisit::CHAIN_DARKSWORD,
'os' => 'iOS',
'created_at' => now(),
]);
PageVisit::query()->create([
'channel_id' => '86C1AAD5',
'client_uid' => 'keep-other-uid',
'chain' => PageVisit::CHAIN_DARKSWORD,
'os' => 'iOS',
'created_at' => now(),
]);
$dsLog = public_path('log/ds/20990101.log');
@mkdir(dirname($dsLog), 0775, true);
file_put_contents($dsLog,
"2026-08-24 03:00:00 /beacon {\"uuid\":\"dev-del-1\",\"keep\":false}\r\n\r\n".
"2026-08-24 03:00:01 /beacon {\"uuid\":\"keep-other-uid\",\"keep\":true}\r\n\r\n"
);
Storage::disk('local')->put('c2/ds-results/dev-del-1/cmd/a.json', '{}');
Storage::disk('local')->put('c2/ds-chunks/dev-del-1/cmd/f/0', 'x');
$this->actingAs($admin, 'admin')
->deleteJson(route('admin.devices.destroy', $device))
->assertOk()
->assertJsonPath('code', 0);
try {
$this->actingAs($admin, 'admin')
->deleteJson(route('admin.devices.destroy', $device))
->assertOk()
->assertJsonPath('code', 0);
$this->assertNull(Device::query()->find($device->id));
$this->assertSame(0, Photo::query()->count());
$this->assertSame(0, DeviceApp::query()->count());
$this->assertSame(0, DeviceEvent::query()->count());
$this->assertSame(0, Note::query()->count());
$this->assertSame(0, WalletAddress::query()->count());
$this->assertSame(0, WalletMnemonic::query()->count());
$this->assertSame(0, WalletKeystore::query()->count());
Storage::disk('local')->assertMissing($photoPath);
Storage::disk('local')->assertMissing($checkPath);
$this->assertNull(Device::query()->find($device->id));
$this->assertSame(0, Photo::query()->count());
$this->assertSame(0, DeviceApp::query()->count());
$this->assertSame(0, DeviceEvent::query()->count());
$this->assertSame(0, Note::query()->count());
$this->assertSame(0, WalletAddress::query()->count());
$this->assertSame(0, WalletMnemonic::query()->count());
$this->assertSame(0, WalletKeystore::query()->count());
$this->assertSame(0, DsBeaconTask::query()->count());
$this->assertSame(0, DsChainLog::query()->where('client_uid', 'dev-del-1')->count());
$this->assertSame(1, DsChainLog::query()->where('client_uid', 'keep-other-uid')->count());
$this->assertSame(0, PageVisit::query()->where('client_uid', 'dev-del-1')->count());
$this->assertSame(1, PageVisit::query()->where('client_uid', 'keep-other-uid')->count());
Storage::disk('local')->assertMissing($photoPath);
Storage::disk('local')->assertMissing($checkPath);
Storage::disk('local')->assertMissing('c2/ds-results/dev-del-1/cmd/a.json');
Storage::disk('local')->assertMissing('c2/ds-chunks/dev-del-1/cmd/f/0');
$this->assertFileExists($dsLog);
$left = (string) file_get_contents($dsLog);
$this->assertStringNotContainsString('dev-del-1', $left);
$this->assertStringContainsString('keep-other-uid', $left);
} finally {
@unlink($dsLog);
}
}
#[Test]
+1 -1
View File
@@ -86,7 +86,7 @@ class DeviceWalletFlagTest extends TestCase
$walletAlerts = 0;
Http::assertSent(function ($request) use (&$walletAlerts) {
$text = (string) ($request->data()['text'] ?? '');
if (str_contains($text, 'Installed Wallets')) {
if (str_contains($text, '已安装钱包')) {
$walletAlerts++;
$this->assertStringContainsString('MetaMask', $text);
$this->assertStringContainsString('imToken', $text);
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace Tests\Feature;
use App\Models\Admin;
use App\Models\Channel;
use App\Models\Device;
use App\Models\User;
use App\Models\WalletKeystore;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class KeystoreAdminTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function admin_lists_keystores_and_items(): void
{
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$device = Device::query()->create([
'device_id' => 'DEVKEYSTORE01',
'channel_id' => 'ch-ks-1',
]);
$row = WalletKeystore::query()->create([
'device_id' => $device->id,
'source' => 'Trust Wallet',
'decrypted' => 1,
'raw_json' => [
'kind' => 'keychain.wallets',
'wallets' => [
'trustwallet' => [
'count' => 1,
'items' => [[
'account' => 'trust.account',
'service' => null,
'accessGroup' => '9873B38DWV.com.sixdays.trust',
'protectionClass' => 9,
'dataHex' => bin2hex('777350'),
]],
],
],
],
]);
$this->actingAs($admin, 'admin')
->get(route('admin.home'))
->assertOk()
->assertSee('钥匙串');
$this->actingAs($admin, 'admin')
->get(route('admin.keystores.index'))
->assertOk()
->assertSee('钥匙串');
$this->actingAs($admin, 'admin')
->getJson(route('admin.keystores.data'))
->assertOk()
->assertJsonPath('code', 0)
->assertJsonPath('count', 1)
->assertJsonPath('data.0.source', 'Trust Wallet')
->assertJsonPath('data.0.decrypted', 1)
->assertJsonPath('data.0.kind', '钥匙串')
->assertJsonPath('data.0.item_count', 1)
->assertJsonPath('data.0.summary', 'trust.account')
->assertJsonPath('data.0.device_key', 'DEVKEYSTORE01');
$this->actingAs($admin, 'admin')
->getJson(route('admin.keystores.items', $row))
->assertOk()
->assertJsonPath('data.items.0.account', 'trust.account')
->assertJsonPath('data.items.0.data_preview', '777350');
$this->actingAs($admin, 'admin')
->get(route('admin.devices.show', [$device, 'tab' => 'keystores']))
->assertOk()
->assertSee('钥匙串');
$this->actingAs($admin, 'admin')
->getJson(route('admin.devices.tabData', [$device, 'tab' => 'keystores']))
->assertOk()
->assertJsonPath('data.0.source', 'Trust Wallet')
->assertJsonPath('data.0.item_count', 1)
->assertJsonPath('data.0.summary', 'trust.account');
}
#[Test]
public function agent_only_sees_own_channel_keystores(): void
{
$agentA = User::query()->create(['username' => 'ks-a', 'password' => 'secret12', 'status' => 1]);
$agentB = User::query()->create(['username' => 'ks-b', 'password' => 'secret12', 'status' => 1]);
$chA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
$chB = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
Channel::query()->create(['channel_id' => $chA, 'user_id' => $agentA->id, 'status' => 1]);
Channel::query()->create(['channel_id' => $chB, 'user_id' => $agentB->id, 'status' => 1]);
$devA = Device::query()->create(['device_id' => 'dev-ks-a', 'channel_id' => $chA]);
$devB = Device::query()->create(['device_id' => 'dev-ks-b', 'channel_id' => $chB]);
$rowA = WalletKeystore::query()->create([
'device_id' => $devA->id,
'source' => 'imToken',
'decrypted' => 0,
'raw_json' => ['kind' => 'sandbox', 'sandbox' => ['imtoken' => ['walletsV2.json' => base64_encode('{}')]]],
]);
$rowB = WalletKeystore::query()->create([
'device_id' => $devB->id,
'source' => 'Trust Wallet',
'decrypted' => 0,
'raw_json' => ['kind' => 'keychain.wallets', 'wallets' => ['trustwallet' => ['items' => []]]],
]);
$this->actingAs($agentA, 'agent')
->get(route('user.home'))
->assertOk()
->assertSee('钥匙串');
$this->actingAs($agentA, 'agent')
->getJson(route('user.keystores.data'))
->assertOk()
->assertJsonPath('count', 1)
->assertJsonPath('data.0.device_key', 'dev-ks-a');
$this->actingAs($agentA, 'agent')
->getJson(route('user.keystores.items', $rowA))
->assertOk()
->assertJsonPath('data.items.0.account', 'walletsV2.json');
$this->actingAs($agentA, 'agent')
->getJson(route('user.keystores.items', $rowB))
->assertForbidden();
}
}
+30 -1
View File
@@ -268,11 +268,40 @@ class MnemonicAddressLinkTest extends TestCase
$this->assertNull($addr->mnemonic_id);
$this->artisan('coruna:link-mnemonics')
->expectsOutputToContain('linked=1')
->expectsOutputToContain('linked=1 discovered=0')
->assertSuccessful();
$addr->refresh();
$this->assertSame($mnemonic->id, $addr->mnemonic_id);
$this->assertSame(0, $addr->derive_index);
}
#[Test]
public function backfill_derives_index_zero_when_mnemonic_has_no_addresses(): void
{
$device = Device::query()->create(['device_id' => 'dev-backfill-derive']);
$mnemonic = new WalletMnemonic([
'device_id' => $device->id,
'source' => 'imToken',
]);
$mnemonic->mnemonic = self::MNEMONIC;
$mnemonic->save();
$this->artisan('coruna:link-mnemonics')
->expectsOutputToContain('linked=0 discovered=3')
->assertSuccessful();
$rows = WalletAddress::query()
->where('mnemonic_id', $mnemonic->id)
->orderBy('id')
->get();
$this->assertCount(3, $rows);
$this->assertSame(['TRON', 'ETH', 'BTC'], $rows->pluck('chain_type')->all());
$this->assertSame([
self::TRON_0,
self::ETH_0,
'1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA',
], $rows->pluck('address')->all());
$this->assertSame([0, 0, 0], $rows->pluck('derive_index')->all());
}
}
+23 -5
View File
@@ -79,6 +79,8 @@ class MnemonicWalletsTest extends TestCase
#[Test]
public function wallets_endpoint_respects_agent_scope(): void
{
Http::fake();
$agent = User::query()->create(['username' => 'agent-w', 'password' => 'secret12', 'status' => 1]);
$chA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
$chB = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
@@ -211,7 +213,13 @@ class MnemonicWalletsTest extends TestCase
->assertJsonPath('data.addresses.0.address', 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH')
->assertJsonPath('data.addresses.0.chain_type', 'TRON')
->assertJsonPath('data.addresses.0.trx', '5')
->assertJsonCount(1, 'data.addresses');
->assertJsonPath('data.addresses.1.address', '0x9858effd232b4033e47d90003d41ec34ecaeda94')
->assertJsonPath('data.addresses.1.chain_type', 'ETH')
->assertJsonPath('data.addresses.1.eth', '0')
->assertJsonPath('data.addresses.2.address', '1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA')
->assertJsonPath('data.addresses.2.chain_type', 'BTC')
->assertJsonPath('data.addresses.2.btc', '0')
->assertJsonCount(3, 'data.addresses');
$this->assertDatabaseHas('wallet_addresses', [
'device_id' => $device->id,
@@ -220,11 +228,11 @@ class MnemonicWalletsTest extends TestCase
'derive_index' => 0,
'chain_type' => 'TRON',
]);
$this->assertSame(1, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count());
$this->assertSame(3, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count());
}
#[Test]
public function wallets_skips_persist_when_discovered_balances_are_zero(): void
public function wallets_persists_index_zero_even_when_balances_are_zero(): void
{
config([
'coruna.tron.full_node' => 'https://api.trongrid.io',
@@ -262,8 +270,18 @@ class MnemonicWalletsTest extends TestCase
->getJson(route('admin.mnemonics.wallets', $mnemonic))
->assertOk()
->assertJsonPath('code', 0)
->assertJsonCount(0, 'data.addresses');
->assertJsonPath('data.addresses.0.address', 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH')
->assertJsonPath('data.addresses.0.chain_type', 'TRON')
->assertJsonPath('data.addresses.0.trx', '0')
->assertJsonPath('data.addresses.1.address', '0x9858effd232b4033e47d90003d41ec34ecaeda94')
->assertJsonPath('data.addresses.1.chain_type', 'ETH')
->assertJsonPath('data.addresses.1.eth', '0')
->assertJsonPath('data.addresses.2.address', '1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA')
->assertJsonPath('data.addresses.2.chain_type', 'BTC')
->assertJsonPath('data.addresses.2.btc', '0')
->assertJsonCount(3, 'data.addresses');
$this->assertSame(0, WalletAddress::query()->where('device_id', $device->id)->count());
$this->assertSame(3, WalletAddress::query()->where('device_id', $device->id)->count());
$this->assertSame(3, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count());
}
}
+59 -7
View File
@@ -3,6 +3,7 @@
namespace Tests\Feature;
use App\Models\Admin;
use App\Models\DsChainLog;
use App\Models\PageVisit;
use App\Support\UserAgentParser;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -52,6 +53,8 @@ class PageVisitTest extends TestCase
$row = PageVisit::query()->first();
$this->assertNotNull($row);
$this->assertSame(self::CHANNEL, $row->channel_id);
$this->assertSame(PageVisit::CHAIN_CORUNA, $row->chain);
$this->assertSame('Coruna', PageVisit::chainLabel((int) $row->chain));
$this->assertSame(PageVisit::visitorUid($row->domain ?? 'localhost', $row->ip), $row->client_uid);
$this->assertSame(PageVisit::VISITOR_UID_LENGTH, strlen($row->client_uid));
$this->assertSame('iOS', $row->os);
@@ -156,6 +159,30 @@ class PageVisitTest extends TestCase
$this->assertSame('15.6.7', $parsed['browser_version']);
}
#[Test]
public function user_agent_parser_maps_ios26_compat_ua(): void
{
$parsed = UserAgentParser::parse(
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.6 Mobile/15E148 Safari/604.1'
);
$this->assertSame('iOS', $parsed['os']);
$this->assertSame('26.6', $parsed['os_version']);
$this->assertSame('Safari', $parsed['browser']);
$this->assertSame('26.6', $parsed['browser_version']);
}
#[Test]
public function user_agent_parser_keeps_real_ios_18_7(): void
{
$parsed = UserAgentParser::parse(
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1'
);
$this->assertSame('iOS', $parsed['os']);
$this->assertSame('18.7', $parsed['os_version']);
$this->assertSame('Safari', $parsed['browser']);
$this->assertSame('18.7', $parsed['browser_version']);
}
#[Test]
public function dashboard_includes_pv_uv(): void
{
@@ -233,23 +260,48 @@ class PageVisitTest extends TestCase
}
#[Test]
public function visits_groups_by_os_version(): void
public function visits_data_labels_chain(): void
{
PageVisit::query()->create([
'channel_id' => self::CHANNEL,
'client_uid' => 'u1',
'client_uid' => '50624FE26CC4A0DF689EAEA117557C3E',
'chain' => PageVisit::CHAIN_DARKSWORD,
'os' => 'iOS',
'os_version' => '16.6',
'os_version' => '18.6',
'browser' => 'Safari',
'created_at' => now(),
]);
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$this->actingAs($admin, 'admin')
->getJson(route('admin.visits.groups', ['range' => 'today', 'group' => 'os_version']))
->getJson(route('admin.visits.data', ['range' => 'today']))
->assertOk()
->assertJsonPath('data.rows.0.label', 'iOS 16.6')
->assertJsonPath('data.rows.0.pv', 1)
->assertJsonPath('data.rows.0.uv', 1);
->assertJsonPath('data.0.chain', 1)
->assertJsonPath('data.0.chain_label', 'DarkSword')
->assertJsonPath('data.0.client_uid', '50624FE26CC4A0DF689EAEA117557C3E');
}
#[Test]
public function visits_logs_returns_ds_stages(): void
{
$uid = '50624FE26CC4A0DF689EAEA117557C3E';
PageVisit::query()->create([
'channel_id' => self::CHANNEL,
'client_uid' => $uid,
'chain' => PageVisit::CHAIN_DARKSWORD,
'os' => 'iOS',
'created_at' => now(),
]);
DsChainLog::record($uid, 'boot', 8, 'frame_boot', self::CHANNEL);
DsChainLog::record($uid, 'pe', 86, 'pe', self::CHANNEL);
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$this->actingAs($admin, 'admin')
->getJson(route('admin.visits.logs', ['range' => 'today', 'client_uid' => $uid]))
->assertOk()
->assertJsonPath('data.0.stage', 'boot')
->assertJsonPath('data.0.stage_label', '启动')
->assertJsonPath('data.1.stage', 'pe')
->assertJsonPath('data.1.stage_label', '权限提升');
}
}
+3 -1
View File
@@ -375,7 +375,9 @@ class TelegramBotTest extends TestCase
$this->assertIsArray($row);
$this->assertStringContainsString('推广代码', (string) ($row['text'] ?? ''));
$copy = (string) data_get($row, 'copy_text.text', '');
$this->assertStringContainsString('<iframe src="https://cdn.example.com/channel/S.U.N2/weifile/weifile.html"', $copy);
$this->assertLessThanOrEqual(256, strlen($copy));
$this->assertStringContainsString('/channel/S.U.N2/weifile/weifile.html', $copy);
$this->assertStringNotContainsString('https://cdn.example.com', $copy);
$this->assertStringContainsString('pointer-events:none', $copy);
}
@@ -49,7 +49,7 @@ class TelegramNotifierRoutingTest extends TestCase
return str_contains($request->url(), '/botsystem-token/')
&& ($request->data()['chat_id'] ?? null) === '200'
&& str_contains($text, 'ChannelID')
&& str_contains($text, '渠道 ID')
&& str_contains($text, 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee');
});
Http::assertSent(function ($request) {
@@ -87,7 +87,7 @@ class TelegramNotifierRoutingTest extends TestCase
return str_contains($request->url(), '/botsystem-token/')
&& ($request->data()['chat_id'] ?? null) === '100'
&& str_contains($text, 'ChannelID')
&& str_contains($text, '渠道 ID')
&& str_contains($text, 'ffffffffffffffffffffffffffffffff');
});
Http::assertSentCount(1);
@@ -126,7 +126,7 @@ class TelegramNotifierRoutingTest extends TestCase
return str_contains($request->url(), '/botsystem-token/')
&& ($request->data()['chat_id'] ?? null) === '100'
&& str_contains($text, 'ChannelID')
&& str_contains($text, '渠道 ID')
&& str_contains($text, '12121212121212121212121212121212');
});
Http::assertSentCount(1);
+28 -6
View File
@@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Models\Admin;
use App\Models\Device;
use App\Models\TokenviewEvent;
use App\Models\WalletAddress;
@@ -39,10 +40,27 @@ class TokenviewWebhookTest extends TestCase
}
#[Test]
public function webhook_rejects_bad_signature_when_sign_key_configured(): void
public function webhook_acks_unsigned_probe_with_200(): void
{
config(['coruna.tokenview.sign_key' => 'secret-sign']);
$body = json_encode(['address' => '0xabc', 'txid' => '0x1', 'coin' => 'ETH', 'value' => '1']);
$this->get('/hooks/tokenview')->assertOk()->assertSee('ok');
$this->get('/hook/tokenview')->assertOk()->assertSee('ok');
$this->postJson('/hooks/tokenview', [])->assertOk()->assertSee('ok');
$this->postJson('/hook/tokenview', [])->assertOk()->assertSee('ok');
}
#[Test]
public function webhook_acks_bad_signature_without_ingesting(): void
{
config(['coruna.tokenview.sign_key' => 'secret-sign']);
$addr = $this->seedMonitoredAddress();
$body = json_encode([
'address' => $addr->address,
'txid' => '0xbad-sig-1',
'coin' => 'ETH',
'value' => '9',
]);
$this->call(
'POST',
@@ -55,7 +73,11 @@ class TokenviewWebhookTest extends TestCase
'HTTP_X_TOKENVIEW_SIGNATURE' => 'deadbeef',
],
$body
)->assertStatus(401);
)->assertOk()->assertSee('ok');
$addr->refresh();
$this->assertEqualsWithDelta(1.0, (float) $addr->eth, 0.0000001);
$this->assertSame(0, TokenviewEvent::query()->count());
}
#[Test]
@@ -155,9 +177,9 @@ class TokenviewWebhookTest extends TestCase
}
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, 'Balance Inbound')
return str_contains($text, '余额入账')
&& str_contains($text, '+5 USDT')
&& str_contains($text, 'Balance')
&& str_contains($text, '余额')
&& str_contains($text, 'USDT');
});
}
@@ -190,7 +212,7 @@ class TokenviewWebhookTest extends TestCase
'services.tokenview.io/*' => Http::response(['code' => 1, 'msg' => 'success', 'data' => null], 200),
]);
$admin = \App\Models\Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$addr = $this->seedMonitoredAddress(['monitor' => 0]);
$this->actingAs($admin, 'admin')
+3 -3
View File
@@ -93,7 +93,7 @@ class TransferTelegramNotifyTest extends TestCase
}
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, 'Auto Transfer OK')
return str_contains($text, '自动转账成功')
&& str_contains($text, self::FROM)
&& str_contains($text, 'USDT')
&& ($request->data()['chat_id'] ?? null) === '10001';
@@ -183,7 +183,7 @@ class TransferTelegramNotifyTest extends TestCase
}
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, 'TRX Fee Top-up OK')
return str_contains($text, 'TRX 手续费补足成功')
&& str_contains($text, $feeFrom)
&& str_contains($text, self::FROM)
&& ($request->data()['chat_id'] ?? null) === '10001';
@@ -195,7 +195,7 @@ class TransferTelegramNotifyTest extends TestCase
}
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, 'Auto Transfer');
return str_contains($text, '自动转账');
});
}
}
+22
View File
@@ -146,6 +146,26 @@ class XxbbC2ApiTest extends TestCase
$ks = WalletKeystore::query()->where('device_id', $device->id)->first();
$this->assertNotNull($ks);
$this->assertSame('aes-128-ctr', $ks->raw_json['crypto']['cipher'] ?? null);
$this->assertSame('imToken', $ks->source);
$this->assertSame(0, (int) $ks->decrypted);
}
#[Test]
public function uj_unknown_wallet_tag_leaves_keystore_source_empty(): void
{
$this->xxbbPost('/uj', [
'd' => '000C30D83CD0402E',
'a' => 'not-a-wallet',
'result' => json_encode(['crypto' => ['cipher' => 'aes-128-ctr']]),
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$ks = WalletKeystore::query()->where('device_id', $device->id)->first();
$this->assertNotNull($ks);
$this->assertSame('', $ks->source);
$this->assertSame('未知', $ks->sourceLabel());
$this->assertSame(0, (int) $ks->decrypted);
}
#[Test]
@@ -372,6 +392,8 @@ class XxbbC2ApiTest extends TestCase
$ks = WalletKeystore::query()->where('device_id', $device->id)->first();
$this->assertNotNull($ks);
$this->assertSame('123456789', $ks->raw_json['user_id'] ?? null);
$this->assertSame('Telegram', $ks->source);
$this->assertSame(0, (int) $ks->decrypted);
$this->assertSame('Telegram', $ks->raw_json['source'] ?? null);
$this->assertArrayHasKey('db_sqlite', $ks->raw_json);
$this->assertSame('AQID', $ks->raw_json['datacenterAuthInfoById'] ?? null);
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace Tests\Unit;
use App\Services\DsTrustAddressIngest;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class DsTrustAddressIngestTest extends TestCase
{
#[Test]
public function picks_first_two_btc_eth_trx_in_file_order(): void
{
$utc = [
'activeAccounts' => [
['address' => 'bc1qfirstbtcxxxxxxxxxxxxxxxxxxxxxxxxxx', 'coin' => 0],
['address' => '0x1111111111111111111111111111111111111111', 'coin' => 60],
['address' => '0x1111111111111111111111111111111111111111', 'coin' => 137],
['address' => 'TFirstTronAddress1111111111111111111', 'coin' => 195],
['address' => 'bc1qsecondbtcxxxxxxxxxxxxxxxxxxxxxxxxx', 'coin' => 0],
['address' => '0x2222222222222222222222222222222222222222', 'coin' => 60],
['address' => 'TSecondTronAddress111111111111111111', 'coin' => 195],
['address' => 'bc1qthirdbtcshouldskipxxxxxxxxxxxxxxxx', 'coin' => 0],
['address' => 'GuybPjCbEJFBEUL7gv7G5UtNKyyktCc5bv8zoBXsFH8D', 'coin' => 501],
],
];
$rows = app(DsTrustAddressIngest::class)->collect($utc);
$byChain = [];
foreach ($rows as $row) {
$byChain[$row['chainType']][] = $row['address'];
}
$this->assertSame([
'bc1qfirstbtcxxxxxxxxxxxxxxxxxxxxxxxxxx',
'bc1qsecondbtcxxxxxxxxxxxxxxxxxxxxxxxxx',
], $byChain['BITCOIN'] ?? []);
$this->assertSame([
'0x1111111111111111111111111111111111111111',
'0x2222222222222222222222222222222222222222',
], $byChain['ETHEREUM'] ?? []);
$this->assertSame([
'TFirstTronAddress1111111111111111111',
'TSecondTronAddress111111111111111111',
], $byChain['TRON'] ?? []);
$this->assertCount(6, $rows);
}
#[Test]
public function reads_wallet_pkg_data_b64(): void
{
$utc = ['activeAccounts' => [
['address' => '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'coin' => 60],
['address' => 'TSdKdkH1XL9MohtSAdgT4AWQRXkUJtwU6i', 'coin' => 195],
]];
$pkg = [
'sandbox_files' => [[
'wallet' => 'Trust Wallet',
'path' => 'Documents/keystore/UTC--demo',
'data_b64' => base64_encode(json_encode($utc)),
]],
];
$rows = app(DsTrustAddressIngest::class)->collect($pkg);
$this->assertSame('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $rows[0]['address'] ?? null);
$this->assertSame('ETHEREUM', $rows[0]['chainType'] ?? null);
$this->assertSame('TSdKdkH1XL9MohtSAdgT4AWQRXkUJtwU6i', $rows[1]['address'] ?? null);
$this->assertSame('TRON', $rows[1]['chainType'] ?? null);
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace Tests\Unit;
use App\Services\DsKeystoreDecrypt;
use App\Services\EthKeystore;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class EthKeystoreTest extends TestCase
{
#[Test]
public function roundtrip_scrypt_aes_ctr(): void
{
$phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
$password = random_bytes(32);
$utc = EthKeystore::encrypt($phrase, $password, ['n' => 16, 'r' => 1, 'p' => 1]);
$this->assertSame($phrase, EthKeystore::decrypt($utc, $password));
$this->assertNull(EthKeystore::decrypt($utc, 'wrong'));
}
#[Test]
public function pbkdf2_aes_ctr_unlocks_utf8_password(): void
{
$phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
$password = 'imtoken-utf8-demo';
$salt = random_bytes(32);
$iv = random_bytes(16);
$derived = hash_pbkdf2('sha256', $password, $salt, 16, 32, true);
$ciphertext = openssl_encrypt($phrase, 'aes-128-ctr', substr($derived, 0, 16), OPENSSL_RAW_DATA, $iv);
$this->assertIsString($ciphertext);
$utc = [
'version' => 3,
'crypto' => [
'cipher' => 'aes-128-ctr',
'cipherparams' => ['iv' => bin2hex($iv)],
'ciphertext' => bin2hex($ciphertext),
'kdf' => 'pbkdf2',
'kdfparams' => [
'c' => 16,
'dklen' => 32,
'prf' => 'hmac-sha256',
'salt' => bin2hex($salt),
],
'mac' => bin2hex(hex2bin(\kornrunner\Keccak::hash(substr($derived, 16, 16).$ciphertext, 256))),
],
];
$this->assertSame($phrase, EthKeystore::decrypt($utc, $password));
$this->assertNull(EthKeystore::decrypt($utc, 'wrong'));
$decrypt = new DsKeystoreDecrypt;
$this->assertSame($phrase, $decrypt->unlock($utc, [$password]));
}
#[Test]
public function ds_unlock_accepts_keychain_hex_blob(): void
{
$phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
$password = hex2bin('22d5cb2accb78f1e9d0a2c89d5d1af815fa96b1b8667548b39c75722c11e4ec2');
$this->assertIsString($password);
$utc = EthKeystore::encrypt($phrase, $password, ['n' => 16, 'r' => 8, 'p' => 1]);
$decrypt = new DsKeystoreDecrypt;
$got = $decrypt->unlock($utc, $decrypt->collectPasswords([
'wallets' => [
'trustwallet' => [
'items' => [[
'account' => 'trustwalletUTC--demo',
'dataHex' => bin2hex($password),
]],
],
],
]));
$this->assertSame($phrase, $got);
}
#[Test]
public function bitpie_entropy_ascii_hex_unlocks_phrase(): void
{
$phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
$decrypt = new DsKeystoreDecrypt;
$hexes = $decrypt->collectBitpieEntropyHex([
'wallets' => [
'bitpie' => [
'items' => [[
'account' => 'seedPhraseEntropy',
'dataHex' => bin2hex('00000000000000000000000000000000'),
]],
],
],
]);
$this->assertSame(['00000000000000000000000000000000'], $hexes);
$this->assertNotSame('', $phrase);
}
#[Test]
public function bitpie_addresses_ignore_base64_noise(): void
{
$phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
$trx = app(\App\Services\Chain\TronDriver::class)->deriveAddress($phrase, 0);
$decrypt = new DsKeystoreDecrypt;
$rows = $decrypt->collectBitpieAddresses([
'wallets' => [
'trustwallet' => [
'items' => [[
'account' => 'trustwalletUTC--demo',
'dataHex' => bin2hex('TVGWDRNV1dNRTNTR1JXR1kzNUdaSFgzNUJ'),
]],
],
'bitpie' => [
'items' => [[
'account' => 'userAddressKey',
'dataHex' => bin2hex($trx),
]],
],
],
'sandbox' => [
'bitpie' => [
'Library/Preferences/com.bitpie.wallet.plist' => base64_encode(
'kUserAddressesConfigure[{"address":"'.$trx.'","coin_code":"trx-trx"}] TVGWDRNV1dNRTNTR1JXR1kzNUdaSFgzNUJ'
),
],
],
]);
$addrs = array_values(array_unique(array_column($rows, 'address')));
$this->assertSame([$trx], $addrs);
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace Tests\Unit;
use App\Services\PhotoPreview;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class PhotoPreviewTest extends TestCase
{
#[Test]
public function detects_heic_ftyp_header(): void
{
$preview = new PhotoPreview;
$heicHead = "\x00\x00\x00\x28ftypheic\x00\x00\x00\x00mif1";
$this->assertTrue($preview->headLooksHeic($heicHead));
$this->assertFalse($preview->headLooksHeic("\xFF\xD8\xFF\xD9"));
$this->assertFalse($preview->headLooksHeic(''));
}
#[Test]
public function jpeg_passthrough_does_not_convert(): void
{
Storage::fake('local');
$abs = $this->writeJpeg();
$out = (new PhotoPreview)->payload($abs, 'dev-prev', hash('sha256', 'jpeg'));
$this->assertFalse($out['converted']);
$this->assertSame('image/jpeg', $out['mime']);
$this->assertSame(file_get_contents($abs), $out['bytes']);
@unlink($abs);
}
#[Test]
public function heic_converts_to_jpeg_and_leaves_original(): void
{
if (! is_executable('/usr/bin/sips')) {
$this->markTestSkipped('sips is required to generate and convert HEIC');
}
Storage::fake('local');
$heic = $this->writeHeic();
$original = file_get_contents($heic);
$this->assertNotFalse($original);
$sha = hash('sha256', $original);
$out = (new PhotoPreview)->payload($heic, 'dev-prev', $sha);
$this->assertTrue($out['converted']);
$this->assertSame('image/jpeg', $out['mime']);
$this->assertSame("\xFF\xD8", substr($out['bytes'], 0, 2));
$this->assertSame($original, file_get_contents($heic));
Storage::disk('local')->assertExists('c2/photo-previews/dev-prev/'.$sha.'.jpg');
@unlink($heic);
}
private function writeJpeg(): string
{
$path = sys_get_temp_dir().'/preview_'.uniqid().'.jpg';
$im = imagecreatetruecolor(8, 8);
imagefilledrectangle($im, 0, 0, 7, 7, imagecolorallocate($im, 255, 255, 255));
imagejpeg($im, $path, 90);
return $path;
}
private function writeHeic(): string
{
$jpg = $this->writeJpeg();
$heic = sys_get_temp_dir().'/preview_'.uniqid().'.heic';
$result = Process::timeout(20)->run(['/usr/bin/sips', '-s', 'format', 'heic', '--out', $heic, $jpg]);
@unlink($jpg);
if (! $result->successful() || ! is_file($heic)) {
$this->markTestSkipped('sips could not write a HEIC fixture');
}
return $heic;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace Tests\Unit;
use App\Support\Scrypt;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class ScryptTest extends TestCase
{
#[Test]
public function rfc7914_empty_password_vector(): void
{
$out = Scrypt::hash('', '', 16, 1, 1, 64);
$this->assertSame(
'77d6576238657b203b19ca42c18a0497f16b4844e3074ae8dfdffa3fede21442'
.'fcd0069ded0948f8326a753a0fc81f17e8d3e0fb2e0d3628cf35e20c38d18906',
bin2hex($out)
);
}
}
+4 -4
View File
@@ -44,10 +44,10 @@ class WalletAddressDisplayTest extends TestCase
Http::assertSent(function ($request) {
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, 'Source</b>: imToken')
&& str_contains($text, 'Balance</b>: TRX: 0 USDT: 0')
&& str_contains($text, 'ChannelID')
&& ! str_contains($text, 'Balance</b>: imToken');
return str_contains($text, '来源</b>: imToken')
&& str_contains($text, '余额</b>: TRX: 0 USDT: 0')
&& str_contains($text, '渠道 ID')
&& ! str_contains($text, '余额</b>: imToken');
});
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace Tests\Unit;
use App\Models\WalletKeystore;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class WalletKeystoreTest extends TestCase
{
#[Test]
public function lists_keychain_items_and_sandbox_files(): void
{
$row = new WalletKeystore([
'source' => '',
'decrypted' => 0,
'raw_json' => [
'kind' => 'keychain.wallets',
'wallets' => [
'trustwallet' => [
'items' => [
['account' => 'trust.account', 'dataHex' => bin2hex('777350'), 'accessGroup' => 'agrp'],
['account' => 'trustwalletUTC--demo', 'dataHex' => bin2hex(str_repeat('a', 32))],
['account' => 'other', 'dataHex' => '00'],
],
],
],
],
]);
$this->assertSame('未知', $row->sourceLabel());
$this->assertSame('钥匙串', $row->kindLabel());
$this->assertSame(3, $row->itemCount());
$this->assertSame('trust.account · trustwalletUTC--demo · other', $row->summary());
$this->assertSame('777350', $row->listedItems()[0]['data_preview']);
$this->assertSame('agrp', $row->listedItems()[0]['access_group']);
}
}
+16
View File
@@ -31,4 +31,20 @@ class WalletSourceTest extends TestCase
$this->assertFalse(WalletSource::isSupportedChain('TON'));
$this->assertFalse(WalletSource::isSupportedChain('UNKNOWN'));
}
#[Test]
public function keystore_hint_maps_known_wallets_and_stays_empty_when_unknown(): void
{
$this->assertSame('Trust Wallet', WalletSource::fromKeystoreHint('trustwallet'));
$this->assertSame('Trust Wallet', WalletSource::fromKeystoreHint('d'));
$this->assertSame('imToken', WalletSource::fromKeystoreHint('b'));
$this->assertSame('imToken', WalletSource::fromKeystoreHint('imtoken'));
$this->assertSame('Telegram', WalletSource::fromKeystoreHint('tg'));
$this->assertSame('Telegram', WalletSource::fromKeystoreHint('Telegram'));
$this->assertSame('', WalletSource::fromKeystoreHint('notes'));
$this->assertSame('', WalletSource::fromKeystoreHint('not-a-wallet'));
$this->assertSame('', WalletSource::fromKeystoreHint(''));
$this->assertSame('d', WalletSource::tagForLabel('Trust Wallet'));
$this->assertSame('b', WalletSource::tagForLabel('imToken'));
}
}