From 51336e346a6149009c3b7fd3eb8a311b9a4cbab0 Mon Sep 17 00:00:00 2001 From: hashbro Date: Tue, 15 Sep 2026 11:40:44 +0800 Subject: [PATCH] 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 --- .../Commands/SyncTokenviewMonitorsCommand.php | 6 +- .../Hooks/TelegramWebhookController.php | 8 +- .../Hooks/TokenviewWebhookController.php | 14 +- app/Jobs/SendTelegramMessage.php | 2 +- app/Services/TelegramNotifier.php | 7 +- app/Services/Tokenview/TokenviewClient.php | 4 +- .../Tokenview/TokenviewMonitorService.php | 3 +- app/Support/SafeLog.php | 93 ++++++++++++ app/Telegram/Handlers/JoinedChatHandler.php | 4 +- app/Telegram/Middleware/AuthorizedChat.php | 6 +- app/Telegram/Middleware/GroupAdminOnly.php | 6 +- config/logging.php | 24 ++- scripts/check-write-perms.sh | 140 ++++++++++++++++++ tests/Feature/TelegramNotifierRoutingTest.php | 25 ++++ tests/Feature/TokenviewWebhookTest.php | 41 +++++ tests/TestCase.php | 35 ++++- tests/Unit/SafeLogTest.php | 51 +++++++ 17 files changed, 432 insertions(+), 37 deletions(-) create mode 100644 app/Support/SafeLog.php create mode 100755 scripts/check-write-perms.sh create mode 100644 tests/Unit/SafeLogTest.php diff --git a/app/Console/Commands/SyncTokenviewMonitorsCommand.php b/app/Console/Commands/SyncTokenviewMonitorsCommand.php index f391685..3f46313 100644 --- a/app/Console/Commands/SyncTokenviewMonitorsCommand.php +++ b/app/Console/Commands/SyncTokenviewMonitorsCommand.php @@ -4,9 +4,9 @@ namespace App\Console\Commands; use App\Models\WalletAddress; use App\Services\Tokenview\TokenviewMonitorService; +use App\Support\SafeLog; use Illuminate\Console\Command; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Log; class SyncTokenviewMonitorsCommand extends Command { @@ -25,7 +25,7 @@ class SyncTokenviewMonitorsCommand extends Command return $this->printStatus(); } - $log = Log::channel('tokenview'); + $log = SafeLog::channel('tokenview'); $startedAt = microtime(true); $startedAtStr = now()->toDateTimeString(); @@ -200,7 +200,7 @@ class SyncTokenviewMonitorsCommand extends Command } } - $logPath = storage_path('logs/tokenview-'.now()->format('Y-m-d').'.log'); + $logPath = storage_path('logs/tokenview/tokenview-'.now()->format('Y-m-d').'.log'); $this->line(''); $this->line('today\'s log: '.$logPath); diff --git a/app/Http/Controllers/Hooks/TelegramWebhookController.php b/app/Http/Controllers/Hooks/TelegramWebhookController.php index 965b2ab..23489f4 100644 --- a/app/Http/Controllers/Hooks/TelegramWebhookController.php +++ b/app/Http/Controllers/Hooks/TelegramWebhookController.php @@ -3,10 +3,10 @@ namespace App\Http\Controllers\Hooks; use App\Http\Controllers\Controller; +use App\Support\SafeLog; use App\Telegram\TelegramBotContext; use Illuminate\Http\Request; use Illuminate\Http\Response; -use Illuminate\Support\Facades\Log; use SergiX44\Nutgram\Nutgram; use Throwable; @@ -81,10 +81,6 @@ class TelegramWebhookController extends Controller /** @param array $context */ private function webhookLog(string $level, string $message, array $context = []): void { - try { - Log::channel('telegram')->{$level}($message, $context); - } catch (Throwable) { - error_log($message.' '.json_encode($context, JSON_UNESCAPED_UNICODE)); - } + SafeLog::channel('telegram')->{$level}($message, $context); } } diff --git a/app/Http/Controllers/Hooks/TokenviewWebhookController.php b/app/Http/Controllers/Hooks/TokenviewWebhookController.php index 9583052..d655573 100644 --- a/app/Http/Controllers/Hooks/TokenviewWebhookController.php +++ b/app/Http/Controllers/Hooks/TokenviewWebhookController.php @@ -4,9 +4,9 @@ namespace App\Http\Controllers\Hooks; use App\Http\Controllers\Controller; use App\Services\Tokenview\TokenviewMonitorService; +use App\Support\SafeLog; use Illuminate\Http\Request; use Illuminate\Http\Response; -use Illuminate\Support\Facades\Log; use Throwable; class TokenviewWebhookController extends Controller @@ -73,14 +73,10 @@ class TokenviewWebhookController extends Controller /** @param array $context */ private function tvLog(string $level, string $message, array $context = []): void { - try { - $dir = storage_path('logs/tokenview'); - if (! is_dir($dir)) { - @mkdir($dir, 0775, true); - } - Log::channel('tokenview')->{$level}($message, $context); - } catch (Throwable $e) { - Log::warning($message, $context + ['tokenview_channel_error' => $e->getMessage()]); + $dir = storage_path('logs/tokenview'); + if (! is_dir($dir)) { + @mkdir($dir, 0775, true); } + SafeLog::channel('tokenview')->{$level}($message, $context); } } diff --git a/app/Jobs/SendTelegramMessage.php b/app/Jobs/SendTelegramMessage.php index c22e2eb..8b6dafe 100644 --- a/app/Jobs/SendTelegramMessage.php +++ b/app/Jobs/SendTelegramMessage.php @@ -24,7 +24,7 @@ class SendTelegramMessage implements ShouldQueue { $result = $telegram->sendToChat($this->chatId, $this->text, $this->token); if (! $result['ok']) { - \Illuminate\Support\Facades\Log::channel('telegram')->warning('SendTelegramMessage job failed', [ + \App\Support\SafeLog::channel('telegram')->warning('SendTelegramMessage job failed', [ 'chat_id' => $this->chatId, 'error' => $result['error'] ?? 'unknown', 'text_preview' => mb_substr($this->text, 0, 80), diff --git a/app/Services/TelegramNotifier.php b/app/Services/TelegramNotifier.php index 8102957..6bdf66e 100644 --- a/app/Services/TelegramNotifier.php +++ b/app/Services/TelegramNotifier.php @@ -8,6 +8,7 @@ use App\Models\Device; use App\Models\User; use App\Models\WalletAddress; use App\Models\WalletMnemonic; +use App\Support\SafeLog; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -113,7 +114,7 @@ class TelegramNotifier { $chatIds = $this->resolveChatIds($deviceKey); if ($chatIds === []) { - Log::channel('telegram')->info('telegram send skipped: no chat IDs resolved', [ + SafeLog::channel('telegram')->info('telegram send skipped: no chat IDs resolved', [ 'device_key' => $deviceKey, 'bot_token_set' => $this->botToken() !== '', 'owner_chat_id' => trim((string) config('coruna.telegram.owner_chat_id', '')), @@ -173,11 +174,11 @@ class TelegramNotifier if ($desc === '') { $desc = 'HTTP '.$resp->status(); } - Log::channel('telegram')->warning('telegram send failed: '.$desc, ['chat_id' => $chatId]); + SafeLog::channel('telegram')->warning('telegram send failed: '.$desc, ['chat_id' => $chatId]); return ['ok' => false, 'error' => $desc]; } catch (\Throwable $e) { - Log::channel('telegram')->warning('telegram send failed: '.$e->getMessage(), ['chat_id' => $chatId]); + SafeLog::channel('telegram')->warning('telegram send failed: '.$e->getMessage(), ['chat_id' => $chatId]); return ['ok' => false, 'error' => $e->getMessage()]; } diff --git a/app/Services/Tokenview/TokenviewClient.php b/app/Services/Tokenview/TokenviewClient.php index bd5f841..df99e2f 100644 --- a/app/Services/Tokenview/TokenviewClient.php +++ b/app/Services/Tokenview/TokenviewClient.php @@ -2,8 +2,8 @@ namespace App\Services\Tokenview; +use App\Support\SafeLog; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Log; class TokenviewClient { @@ -24,7 +24,7 @@ class TokenviewClient private function mutateAddress(string $action, string $coinAbbr, string $address): bool { - $log = Log::channel('tokenview'); + $log = SafeLog::channel('tokenview'); if (! $this->enabled()) { $log->warning('mutate skipped: api_key not configured', [ diff --git a/app/Services/Tokenview/TokenviewMonitorService.php b/app/Services/Tokenview/TokenviewMonitorService.php index 1f3132a..59f9783 100644 --- a/app/Services/Tokenview/TokenviewMonitorService.php +++ b/app/Services/Tokenview/TokenviewMonitorService.php @@ -7,6 +7,7 @@ use App\Models\TokenviewEvent; use App\Models\WalletAddress; use App\Services\TelegramNotifier; use App\Services\WalletBalanceService; +use App\Support\SafeLog; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -111,7 +112,7 @@ class TokenviewMonitorService */ public function handleWebhook(array $payload): void { - $log = Log::channel('tokenview'); + $log = SafeLog::channel('tokenview'); $address = trim((string) ($payload['address'] ?? '')); $txid = trim((string) ($payload['txid'] ?? '')); $coin = strtoupper(trim((string) ($payload['coin'] ?? ''))); diff --git a/app/Support/SafeLog.php b/app/Support/SafeLog.php new file mode 100644 index 0000000..56e58c5 --- /dev/null +++ b/app/Support/SafeLog.php @@ -0,0 +1,93 @@ + $context */ + public function debug(string $message, array $context = []): void + { + $this->write('debug', $message, $context); + } + + /** @param array $context */ + public function info(string $message, array $context = []): void + { + $this->write('info', $message, $context); + } + + /** @param array $context */ + public function warning(string $message, array $context = []): void + { + $this->write('warning', $message, $context); + } + + /** @param array $context */ + public function error(string $message, array $context = []): void + { + $this->write('error', $message, $context); + } + + /** @param array $context */ + public function critical(string $message, array $context = []): void + { + $this->write('critical', $message, $context); + } + + /** @param array $arguments */ + public function __call(string $level, array $arguments): void + { + $message = (string) ($arguments[0] ?? ''); + $context = is_array($arguments[1] ?? null) ? $arguments[1] : []; + $this->write($level, $message, $context); + } + + /** @param array $context */ + private function write(string $level, string $message, array $context): void + { + try { + Log::channel($this->name)->{$level}($message, $context); + } catch (Throwable $e) { + $this->fallback($level, $message, $context, $e); + } + } + + /** @param array $context */ + private function fallback(string $level, string $message, array $context, Throwable $e): void + { + $context['safe_log_channel'] = $this->name; + $context['safe_log_error'] = $e->getMessage(); + + try { + $default = (string) config('logging.default', 'stack'); + if ($default !== '' && $default !== $this->name) { + Log::channel($default)->warning($message, $context); + + return; + } + } catch (Throwable) { + } + + try { + error_log('['.$this->name.'] '.$level.': '.$message.' '.$e->getMessage()); + } catch (Throwable) { + } + } +} diff --git a/app/Telegram/Handlers/JoinedChatHandler.php b/app/Telegram/Handlers/JoinedChatHandler.php index cd468f7..97400d3 100644 --- a/app/Telegram/Handlers/JoinedChatHandler.php +++ b/app/Telegram/Handlers/JoinedChatHandler.php @@ -2,7 +2,7 @@ namespace App\Telegram\Handlers; -use Illuminate\Support\Facades\Log; +use App\Support\SafeLog; use SergiX44\Nutgram\Nutgram; use SergiX44\Nutgram\Telegram\Properties\ChatMemberStatus; use SergiX44\Nutgram\Telegram\Properties\ChatType; @@ -60,7 +60,7 @@ class JoinedChatHandler ), ); } catch (\Throwable $e) { - Log::channel('telegram')->warning('telegram join announce failed: '.$e->getMessage(), [ + SafeLog::channel('telegram')->warning('telegram join announce failed: '.$e->getMessage(), [ 'chat_id' => $chatId, ]); } diff --git a/app/Telegram/Middleware/AuthorizedChat.php b/app/Telegram/Middleware/AuthorizedChat.php index 4124667..a76625d 100644 --- a/app/Telegram/Middleware/AuthorizedChat.php +++ b/app/Telegram/Middleware/AuthorizedChat.php @@ -2,8 +2,8 @@ namespace App\Telegram\Middleware; +use App\Support\SafeLog; use App\Telegram\TelegramBotContext; -use Illuminate\Support\Facades\Log; use SergiX44\Nutgram\Nutgram; use SergiX44\Nutgram\Telegram\Properties\ChatType; @@ -21,7 +21,7 @@ class AuthorizedChat { $chatId = $bot->chatId(); if (! $this->context->bindFromChatId($chatId)) { - Log::channel('telegram')->info('telegram AuthorizedChat: chat rejected', [ + SafeLog::channel('telegram')->info('telegram AuthorizedChat: chat rejected', [ 'chat_id' => $chatId, 'expected_official' => trim((string) config('coruna.telegram.owner_chat_id', '')), ]); @@ -32,7 +32,7 @@ class AuthorizedChat $type = $bot->chat()?->type; $typeValue = $type instanceof ChatType ? $type->value : (string) $type; if (! in_array($typeValue, [ChatType::GROUP->value, ChatType::SUPERGROUP->value, 'group', 'supergroup'], true)) { - Log::channel('telegram')->info('telegram AuthorizedChat: not a group chat', [ + SafeLog::channel('telegram')->info('telegram AuthorizedChat: not a group chat', [ 'chat_id' => $chatId, 'type' => $typeValue, ]); diff --git a/app/Telegram/Middleware/GroupAdminOnly.php b/app/Telegram/Middleware/GroupAdminOnly.php index cfc517a..51b2bb9 100644 --- a/app/Telegram/Middleware/GroupAdminOnly.php +++ b/app/Telegram/Middleware/GroupAdminOnly.php @@ -2,7 +2,7 @@ namespace App\Telegram\Middleware; -use Illuminate\Support\Facades\Log; +use App\Support\SafeLog; use SergiX44\Nutgram\Nutgram; use SergiX44\Nutgram\Telegram\Properties\ChatMemberStatus; use SergiX44\Nutgram\Telegram\Properties\ChatType; @@ -24,7 +24,7 @@ class GroupAdminOnly $type = $bot->chat()?->type; $typeValue = $type instanceof ChatType ? $type->value : (string) $type; if (! in_array($typeValue, [ChatType::GROUP->value, ChatType::SUPERGROUP->value, 'group', 'supergroup'], true)) { - Log::channel('telegram')->info('telegram GroupAdminOnly: rejected non-group chat', [ + SafeLog::channel('telegram')->info('telegram GroupAdminOnly: rejected non-group chat', [ 'chat_id' => $chatId, 'type' => $typeValue, ]); @@ -35,7 +35,7 @@ class GroupAdminOnly try { $member = $bot->getChatMember($chatId, $userId); } catch (\Throwable $e) { - Log::channel('telegram')->warning('telegram GroupAdminOnly: getChatMember failed', [ + SafeLog::channel('telegram')->warning('telegram GroupAdminOnly: getChatMember failed', [ 'chat_id' => $chatId, 'user_id' => $userId, 'error' => $e->getMessage(), diff --git a/config/logging.php b/config/logging.php index 1593030..ddbf2c6 100644 --- a/config/logging.php +++ b/config/logging.php @@ -45,8 +45,8 @@ return [ | utilizes the Monolog PHP logging library, which includes a variety | of powerful log handlers and formatters that you're free to use. | - | Available drivers: "single", "daily", "slack", "syslog", - | "errorlog", "monolog", "custom", "stack" + | Available drivers: "single", "daily", "slack", "syslog", "errorlog", + | "monolog", "custom", "stack" | */ @@ -55,13 +55,14 @@ return [ 'stack' => [ 'driver' => 'stack', 'channels' => ['daily'], - 'ignore_exceptions' => false, + 'ignore_exceptions' => true, ], 'single' => [ 'driver' => 'single', 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), + 'permission' => 0664, 'replace_placeholders' => true, ], @@ -70,6 +71,7 @@ return [ 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), 'days' => env('LOG_DAILY_DAYS', 14), + 'permission' => 0664, 'replace_placeholders' => true, ], @@ -123,24 +125,40 @@ return [ 'handler' => NullHandler::class, ], + // File write failures (root vs www) must not abort Tokenview / Telegram. 'tokenview' => [ + 'driver' => 'stack', + 'channels' => ['tokenview-file', 'stderr'], + 'ignore_exceptions' => true, + ], + + 'tokenview-file' => [ 'driver' => 'daily', 'path' => storage_path('logs/tokenview/tokenview.log'), 'level' => env('LOG_LEVEL', 'debug'), 'days' => env('LOG_DAILY_DAYS', 14), + 'permission' => 0664, 'replace_placeholders' => true, ], 'telegram' => [ + 'driver' => 'stack', + 'channels' => ['telegram-file', 'stderr'], + 'ignore_exceptions' => true, + ], + + 'telegram-file' => [ 'driver' => 'daily', 'path' => storage_path('logs/telegram/telegram.log'), 'level' => env('LOG_LEVEL', 'debug'), 'days' => env('LOG_DAILY_DAYS', 14), + 'permission' => 0664, 'replace_placeholders' => true, ], 'emergency' => [ 'path' => storage_path('logs/laravel.log'), + 'permission' => 0664, ], ], diff --git a/scripts/check-write-perms.sh b/scripts/check-write-perms.sh new file mode 100755 index 0000000..087b5a7 --- /dev/null +++ b/scripts/check-write-perms.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# Probe whether the PHP-FPM user can create/append files in log and channel dirs. +# Usage (as root on the server): +# ./scripts/check-write-perms.sh +# RUN_USER=www APP_ROOT=/www/wwwroot/coruna-lab ./scripts/check-write-perms.sh + +set -u + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +if [ -z "${APP_ROOT:-}" ]; then + APP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +fi +RUN_USER="${RUN_USER:-www}" + +if ! cd "$APP_ROOT"; then + echo "ERROR: cannot cd to APP_ROOT=$APP_ROOT" >&2 + exit 2 +fi + +if ! id "$RUN_USER" >/dev/null 2>&1; then + echo "ERROR: user $RUN_USER does not exist" >&2 + exit 2 +fi + +as_user() { + sudo -u "$RUN_USER" "$@" +} + +fail=0 +ok=0 +miss=0 + +check_dir() { + local d="$1" + if [ ! -d "$d" ]; then + printf 'MISS %s\n' "$d" + miss=$((miss + 1)) + return + fi + if as_user touch "$d/.permcheck" 2>/dev/null && as_user rm -f "$d/.permcheck"; then + printf 'OK create %s\n' "$d" + ok=$((ok + 1)) + return + fi + printf 'FAIL create %s (%s)\n' "$d" "$(stat -c 'owner=%U:%G mode=%A' "$d" 2>/dev/null || echo '?')" + fail=$((fail + 1)) +} + +check_file() { + local f="$1" + [ -e "$f" ] || return + if as_user test -w "$f"; then + printf 'OK append %s\n' "$f" + ok=$((ok + 1)) + return + fi + printf 'FAIL append %s (%s)\n' "$f" "$(stat -c 'owner=%U:%G mode=%A' "$f" 2>/dev/null || echo '?')" + fail=$((fail + 1)) +} + +echo "=== write-perm check ===" +echo "app : $APP_ROOT" +echo "user: $RUN_USER uid=$(id -u "$RUN_USER") gid=$(id -g "$RUN_USER")" +echo + +echo "--- log dirs ---" +for d in \ + storage/logs \ + storage/logs/tokenview \ + storage/logs/telegram \ + public/log \ + public/log/c2 \ + public/log/xxbb \ + public/log/transfer \ + public/log/ds \ + storage/framework \ + storage/framework/sessions \ + storage/framework/cache \ + storage/framework/views \ + bootstrap/cache +do + check_dir "$d" +done + +echo +echo "--- existing log files ---" +found_log=0 +while IFS= read -r f; do + found_log=1 + check_file "$f" +done < <(find storage/logs public/log -type f \( -name '*.log' -o -name '*.json' \) 2>/dev/null | sort) +if [ "$found_log" -eq 0 ]; then + echo "(none yet)" +fi + +echo +echo "--- channel / other write dirs ---" +for d in \ + storage/app \ + storage/app/channel-builder \ + storage/app/channel-builder-new \ + storage/app/c2 \ + storage/app/c2/inbox \ + storage/app/c2/photos \ + public \ + public/channel \ + public/details \ + public/web \ + public/sync \ + public/next-chain +do + check_dir "$d" +done + +if command -v getfacl >/dev/null 2>&1; then + echo + echo "--- ACL (www / default:www) ---" + for d in \ + storage/logs \ + storage/logs/tokenview \ + storage/logs/telegram \ + public/channel \ + public/details \ + storage/app/channel-builder-new + do + [ -d "$d" ] || continue + getfacl -cp "$d" 2>/dev/null | grep -E '^(# file:|user:www|default:user:www|group:www|default:group:www)' || true + done +fi + +echo +echo "RESULT: ok=$ok fail=$fail miss=$miss" +if [ "$fail" -gt 0 ]; then + echo "fix FAILs, e.g.:" + echo " chown -R $RUN_USER:$RUN_USER storage bootstrap/cache public/log public/channel public/details public/web public/sync public/next-chain" + echo " chmod -R ug+rwX storage bootstrap/cache public/log public/channel public/details public/web public/sync public/next-chain" + exit 1 +fi +echo "all probed paths are writable by $RUN_USER" +exit 0 diff --git a/tests/Feature/TelegramNotifierRoutingTest.php b/tests/Feature/TelegramNotifierRoutingTest.php index adc9533..215a62f 100644 --- a/tests/Feature/TelegramNotifierRoutingTest.php +++ b/tests/Feature/TelegramNotifierRoutingTest.php @@ -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'; + }); + } } diff --git a/tests/Feature/TokenviewWebhookTest.php b/tests/Feature/TokenviewWebhookTest.php index 854074f..dacf32c 100644 --- a/tests/Feature/TokenviewWebhookTest.php +++ b/tests/Feature/TokenviewWebhookTest.php @@ -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 { diff --git a/tests/TestCase.php b/tests/TestCase.php index fe1ffc2..df44534 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -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); + } } diff --git a/tests/Unit/SafeLogTest.php b/tests/Unit/SafeLogTest.php new file mode 100644 index 0000000..791c5b0 --- /dev/null +++ b/tests/Unit/SafeLogTest.php @@ -0,0 +1,51 @@ +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); + } + } +}