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); + } + } +}