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>
This commit is contained in:
hashbro
2026-09-15 11:40:44 +08:00
parent 74a85b415b
commit 51336e346a
17 changed files with 432 additions and 37 deletions
@@ -131,4 +131,29 @@ class TelegramNotifierRoutingTest extends TestCase
});
Http::assertSentCount(1);
}
#[Test]
public function send_still_works_when_telegram_log_is_unwritable(): void
{
config([
'coruna.telegram.bot_token' => 'system-token',
'coruna.telegram.owner_chat_id' => '100',
]);
$dir = $this->forceLogChannelUnwritable('telegram');
Http::fake(['api.telegram.org/*' => Http::response(['ok' => true], 200)]);
try {
$ok = app(TelegramNotifier::class)->send('hello from unwritable log');
} finally {
$this->restoreWritableLogDir($dir);
}
$this->assertTrue($ok);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/botsystem-token/')
&& ($request->data()['chat_id'] ?? null) === '100'
&& ($request->data()['text'] ?? null) === 'hello from unwritable log';
});
}
}
+41
View File
@@ -356,6 +356,47 @@ class TokenviewWebhookTest extends TestCase
$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
{
+34 -1
View File
@@ -3,8 +3,41 @@
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\Log;
abstract class TestCase extends BaseTestCase
{
//
/**
* Point a Laravel log channel at a directory the process cannot write.
* Used to prove SafeLog / ignore_exceptions do not abort business work.
*/
protected function forceLogChannelUnwritable(string $channel): string
{
$dir = sys_get_temp_dir().'/coruna-unwritable-log-'.uniqid('', true);
mkdir($dir, 0700);
chmod($dir, 0500);
config([
"logging.channels.{$channel}" => [
'driver' => 'single',
'path' => $dir.'/blocked.log',
'level' => 'debug',
],
]);
Log::forgetChannel($channel);
return $dir;
}
protected function restoreWritableLogDir(string $dir): void
{
if ($dir === '' || ! is_dir($dir)) {
return;
}
@chmod($dir, 0700);
foreach (glob($dir.'/*') ?: [] as $file) {
@unlink($file);
}
@rmdir($dir);
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace Tests\Unit;
use App\Support\SafeLog;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
use UnexpectedValueException;
class SafeLogTest extends TestCase
{
public function test_write_does_not_throw_when_channel_is_unwritable(): void
{
$dir = $this->forceLogChannelUnwritable('tokenview');
try {
SafeLog::channel('tokenview')->info('safe-log probe', ['k' => 1]);
SafeLog::channel('tokenview')->warning('safe-log warn');
} finally {
$this->restoreWritableLogDir($dir);
}
$this->assertTrue(true);
}
public function test_tokenview_stack_swallows_file_permission_errors(): void
{
$dir = $this->forceLogChannelUnwritable('tokenview-file');
Log::forgetChannel('tokenview');
try {
Log::channel('tokenview')->info('stack should swallow file errors');
} finally {
$this->restoreWritableLogDir($dir);
}
$this->assertTrue(true);
}
public function test_unwritable_channel_without_safelog_still_throws(): void
{
$dir = $this->forceLogChannelUnwritable('tokenview');
try {
$this->expectException(UnexpectedValueException::class);
Log::channel('tokenview')->info('should throw');
} finally {
$this->restoreWritableLogDir($dir);
}
}
}