Files
coruna-lab/tests/Feature/TokenviewWebhookTest.php
T
hashbro 51336e346a Keep Tokenview and Telegram running when log files are not writable.
Daily logs created by root cron were blocking www from appending, which aborted webhook ingest and bot pushes. File channels now use 0664 plus exception-safe stacks, and writes go through SafeLog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 11:40:44 +08:00

430 lines
14 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Admin;
use App\Models\Device;
use App\Models\TokenviewEvent;
use App\Models\WalletAddress;
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_acks_unsigned_probe_with_200(): void
{
config(['coruna.tokenview.sign_key' => 'secret-sign']);
$this->get('/hooks/tokenview')->assertOk()->assertSee('ok');
$this->get('/hook/tokenview')->assertOk()->assertSee('ok');
$this->postJson('/hooks/tokenview', [])->assertOk()->assertSee('ok');
$this->postJson('/hook/tokenview', [])->assertOk()->assertSee('ok');
}
#[Test]
public function webhook_acks_bad_signature_without_ingesting(): void
{
config(['coruna.tokenview.sign_key' => 'secret-sign']);
$addr = $this->seedMonitoredAddress();
$body = json_encode([
'address' => $addr->address,
'txid' => '0xbad-sig-1',
'coin' => 'ETH',
'value' => '9',
]);
$this->call(
'POST',
'/hooks/tokenview',
[],
[],
[],
[
'CONTENT_TYPE' => 'application/json',
'HTTP_X_TOKENVIEW_SIGNATURE' => 'deadbeef',
],
$body
)->assertOk()->assertSee('ok');
$addr->refresh();
$this->assertEqualsWithDelta(1.0, (float) $addr->eth, 0.0000001);
$this->assertSame(0, TokenviewEvent::query()->count());
}
#[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_refreshes_tron_balances_from_chain_instead_of_delta(): void
{
config(['coruna.tokenview.sign_key' => '']);
Http::fake(function ($request) {
$url = $request->url();
if (str_contains($url, '/v1/accounts/')) {
return Http::response([
'data' => [[
'balance' => 9_000_000,
'trc20' => [
['TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' => '50000000'],
],
]],
'success' => true,
], 200);
}
if (str_contains($url, 'api.telegram.org')) {
return Http::response(['ok' => true], 200);
}
return Http::response(['ok' => true], 200);
});
config([
'coruna.telegram.bot_token' => 'bot-token',
'coruna.telegram.owner_chat_id' => '12345',
]);
$addr = $this->seedMonitoredAddress([
'address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6',
'chain_type' => 'TRON',
'trx' => 1.0,
'usdt' => 10.0,
'eth' => null,
]);
$payload = [
'address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6',
'txid' => 'tron-txid-refresh-1',
'coin' => 'TRX',
'tokenSymbol' => 'USDT',
'tokenValue' => '5',
'value' => '0.1',
];
$this->postJson('/hooks/tokenview', $payload)->assertOk()->assertSee('ok');
$addr->refresh();
// Live chain balances win over webhook deltas.
$this->assertEqualsWithDelta(9.0, (float) $addr->trx, 0.0000001);
$this->assertEqualsWithDelta(50.0, (float) $addr->usdt, 0.0000001);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'api.telegram.org')) {
return false;
}
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, '余额入账')
&& str_contains($text, '+5 USDT')
&& str_contains($text, '余额')
&& str_contains($text, 'USDT');
});
}
#[Test]
public function webhook_notifies_tron_outbound_after_chain_refresh(): void
{
config(['coruna.tokenview.sign_key' => '']);
Http::fake(function ($request) {
$url = $request->url();
if (str_contains($url, '/v1/accounts/')) {
return Http::response([
'data' => [[
'balance' => 15_043_359,
'trc20' => [
['TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' => '45601000'],
],
]],
'success' => true,
], 200);
}
if (str_contains($url, 'api.telegram.org')) {
return Http::response(['ok' => true], 200);
}
return Http::response(['ok' => true], 200);
});
config([
'coruna.telegram.bot_token' => 'bot-token',
'coruna.telegram.owner_chat_id' => '12345',
]);
$addr = $this->seedMonitoredAddress([
'address' => 'TDZFQVZJLW3J7dpS9kCE45C8tUxBLwfinD',
'chain_type' => 'TRON',
'trx' => 15.0,
'usdt' => 545.601,
'eth' => null,
]);
$payload = [
'address' => 'TDZFQVZJLW3J7dpS9kCE45C8tUxBLwfinD',
'txid' => 'a37a08e7cc424528276b7bf9aff5feb8076e14851feb2d2a6e01ac34de68e404',
'coin' => 'TRX',
'tokenSymbol' => 'USDT',
'tokenValue' => '-500',
'value' => '0',
];
$this->postJson('/hooks/tokenview', $payload)->assertOk()->assertSee('ok');
$addr->refresh();
$this->assertEqualsWithDelta(15.043359, (float) $addr->trx, 0.0000001);
$this->assertEqualsWithDelta(45.601, (float) $addr->usdt, 0.0000001);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'api.telegram.org')) {
return false;
}
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, '余额转出')
&& str_contains($text, '-500 USDT')
&& ! str_contains($text, '余额入账')
&& str_contains($text, '余额')
&& str_contains($text, '45.6');
});
}
#[Test]
public function webhook_notifies_native_trx_when_token_symbol_omitted(): void
{
config(['coruna.tokenview.sign_key' => '']);
Http::fake(function ($request) {
$url = $request->url();
if (str_contains($url, '/v1/accounts/')) {
return Http::response([
'data' => [[
'balance' => 12_000_000,
'trc20' => [],
]],
'success' => true,
], 200);
}
if (str_contains($url, 'api.telegram.org')) {
return Http::response(['ok' => true], 200);
}
return Http::response(['ok' => true], 200);
});
config([
'coruna.telegram.bot_token' => 'bot-token',
'coruna.telegram.owner_chat_id' => '12345',
]);
$addr = $this->seedMonitoredAddress([
'address' => 'TWVpqZyczbccBtNNsV8aRmBRfETZBxRkNL',
'chain_type' => 'TRON',
'trx' => 15.0,
'usdt' => 0,
'eth' => null,
]);
$payload = [
'address' => 'TWVpqZyczbccBtNNsV8aRmBRfETZBxRkNL',
'txid' => '02af07be47430d0b1db32962f3036ff75afc1114444a928c44e258cd332c0d0e',
'time' => 1788200361,
'confirmations' => 1,
'value' => '-3',
'coin' => 'TRX',
'height' => 85842934,
'network' => 'TRX',
];
$this->postJson('/hooks/tokenview', $payload)->assertOk()->assertSee('ok');
$this->assertSame(1, TokenviewEvent::query()->count());
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'api.telegram.org')) {
return false;
}
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, '余额转出')
&& str_contains($text, '-3 TRX');
});
}
#[Test]
public function webhook_ignores_tron_when_token_is_not_trx_or_usdt(): void
{
config(['coruna.tokenview.sign_key' => '']);
$addr = $this->seedMonitoredAddress([
'address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6',
'chain_type' => 'TRON',
'trx' => 1.0,
'usdt' => 10.0,
'eth' => null,
]);
$payload = [
'address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6',
'txid' => 'tron-txid-usdc-skip-1',
'coin' => 'TRX',
'tokenSymbol' => 'USDC',
'tokenValue' => '99',
'value' => '0.1',
];
$this->postJson('/hooks/tokenview', $payload)->assertOk()->assertSee('ok');
$addr->refresh();
$this->assertEqualsWithDelta(1.0, (float) $addr->trx, 0.0000001);
$this->assertEqualsWithDelta(10.0, (float) $addr->usdt, 0.0000001);
$this->assertSame(0, 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 webhook_notifies_when_tokenview_log_is_unwritable(): void
{
config(['coruna.tokenview.sign_key' => '']);
Http::fake([
'api.telegram.org/*' => Http::response(['ok' => true], 200),
]);
config([
'coruna.telegram.bot_token' => 'bot-token',
'coruna.telegram.owner_chat_id' => '12345',
]);
$dir = $this->forceLogChannelUnwritable('tokenview');
$addr = $this->seedMonitoredAddress();
$payload = [
'address' => $addr->address,
'txid' => '0xlog-denied-'.str_repeat('a', 50),
'coin' => 'ETH',
'value' => '0.25',
];
try {
$this->postJson('/hooks/tokenview', $payload)->assertOk()->assertSee('ok');
} finally {
$this->restoreWritableLogDir($dir);
}
$addr->refresh();
$this->assertEqualsWithDelta(1.25, (float) $addr->eth, 0.0000001);
$this->assertSame(1, TokenviewEvent::query()->count());
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'api.telegram.org')) {
return false;
}
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, '余额入账')
&& str_contains($text, '+0.25 ETH');
});
}
#[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 = 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/');
});
}
}