diff --git a/CHANGELOG.md b/CHANGELOG.md index 94c1692..978c3c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ ## Unreleased +### Fixed + +- Fixed failed requests never being captured while the application exception handler was active. `Illuminate\Routing\Pipeline` renders throwables into responses before they reach global middleware, so the middleware `catch` block was unreachable outside of `withoutExceptionHandling()`. +- Fixed `laratimecode.replay.restore_auth_user` being ignored: `timecode:replay` always passed an explicit `false` when `--auth` was absent. +- Fixed truncated strings being reported with a literal `\n` instead of a newline. +- Fixed multibyte strings being cut mid-character on truncation, which produced invalid UTF-8 and made the whole snapshot fail to encode. +- Fixed `timecode:list` failing outright when a single snapshot was unreadable, for example after an `APP_KEY` rotation. Unreadable snapshots are now skipped and reported to the log. +- Fixed snapshots being lost when request data contained invalid UTF-8 bytes. +- Fixed the service provider throwing when no HTTP kernel is bound. + +### Changed + +- `ignore_exceptions` now also lists `AuthorizationException`, `RecordsNotFoundException`, and `TokenMismatchException`. Capture sees the original throwable rather than the one Laravel maps it to, so 403, 404, and 419 responses were being recorded despite the intent of the shipped defaults. +- Added `ext-mbstring`, `illuminate/auth`, `illuminate/session`, `psr/log`, `symfony/http-foundation`, and `symfony/http-kernel` as explicit dependencies. + ## 0.1.0-alpha.1 - 2026-08-17 - Added PHP 8.2–8.5 and Laravel 12–13 support. diff --git a/CHANGELOG.ru.md b/CHANGELOG.ru.md index bdbef26..1b9188e 100644 --- a/CHANGELOG.ru.md +++ b/CHANGELOG.ru.md @@ -4,6 +4,21 @@ ## Не выпущено +### Исправлено + +- Исправлено: неудачные запросы вообще не сохранялись при активном обработчике исключений. `Illuminate\Routing\Pipeline` превращает исключение в ответ до возврата в глобальный middleware, поэтому блок `catch` был недостижим вне `withoutExceptionHandling()`. +- Исправлено: параметр `laratimecode.replay.restore_auth_user` не учитывался — команда `timecode:replay` всегда передавала явный `false` при отсутствии `--auth`. +- Исправлено: в обрезанных строках вместо перевода строки подставлялся литерал `\n`. +- Исправлено: обрезка многобайтовых строк разрывала символ, ломала UTF-8 и приводила к потере всего снимка при кодировании. +- Исправлено: команда `timecode:list` падала целиком из-за одного нечитаемого снимка, например после смены `APP_KEY`. Такие файлы теперь пропускаются с записью в журнал. +- Исправлено: снимок терялся, если данные запроса содержали недопустимые байты UTF-8. +- Исправлено: провайдер падал при отсутствии привязанного HTTP-ядра. + +### Изменено + +- В `ignore_exceptions` добавлены `AuthorizationException`, `RecordsNotFoundException` и `TokenMismatchException`. Захват видит исходное исключение, а не то, в которое его преобразует Laravel, поэтому ответы 403, 404 и 419 сохранялись вопреки замыслу поставляемых настроек. +- Явно объявлены зависимости `ext-mbstring`, `illuminate/auth`, `illuminate/session`, `psr/log`, `symfony/http-foundation` и `symfony/http-kernel`. + ## 0.1.0-alpha.1 — 2026-08-17 - Добавлена поддержка PHP 8.2–8.5 и Laravel 12–13. diff --git a/composer.json b/composer.json index 391a580..5d848ce 100644 --- a/composer.json +++ b/composer.json @@ -26,13 +26,19 @@ }, "require": { "ext-json": "*", + "ext-mbstring": "*", "php": "^8.2", + "illuminate/auth": "^12.0|^13.0", "illuminate/console": "^12.0|^13.0", "illuminate/contracts": "^12.0|^13.0", "illuminate/database": "^12.0|^13.0", "illuminate/filesystem": "^12.0|^13.0", "illuminate/http": "^12.0|^13.0", - "illuminate/support": "^12.0|^13.0" + "illuminate/session": "^12.0|^13.0", + "illuminate/support": "^12.0|^13.0", + "psr/log": "^2.0|^3.0", + "symfony/http-foundation": "^7.0|^8.0", + "symfony/http-kernel": "^7.0|^8.0" }, "require-dev": { "laravel/pint": "^1.24", diff --git a/config/laratimecode.php b/config/laratimecode.php index f3d3fd9..79c479c 100644 --- a/config/laratimecode.php +++ b/config/laratimecode.php @@ -2,7 +2,10 @@ declare(strict_types=1); +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\AuthenticationException; +use Illuminate\Database\RecordsNotFoundException; +use Illuminate\Session\TokenMismatchException; use Illuminate\Validation\ValidationException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -51,7 +54,10 @@ 'ignore_exceptions' => [ AuthenticationException::class, + AuthorizationException::class, NotFoundHttpException::class, + RecordsNotFoundException::class, + TokenMismatchException::class, ValidationException::class, ], ], diff --git a/src/Capture/FailureRecorder.php b/src/Capture/FailureRecorder.php index 2968103..1a8c720 100644 --- a/src/Capture/FailureRecorder.php +++ b/src/Capture/FailureRecorder.php @@ -12,6 +12,7 @@ use LaraTimeCode\Contracts\SnapshotRepository; use LaraTimeCode\Redaction\Redactor; use LaraTimeCode\Support\SnapshotId; +use Psr\Log\LoggerInterface; use Throwable; final class FailureRecorder @@ -22,8 +23,36 @@ public function __construct( private readonly CaptureContext $context, private readonly Config $config, private readonly Application $app, + private readonly LoggerInterface $logger, ) {} + public function captureIfNeeded(Request $request, Throwable $exception): ?string + { + $recordedId = $request->attributes->get('_laratimecode_id'); + + if (is_string($recordedId)) { + return $recordedId; + } + + if (! $this->shouldCapture($request, $exception)) { + return null; + } + + try { + $id = $this->capture($request, $exception); + } catch (Throwable $captureException) { + $this->logger->warning('LaraTimeCode could not capture a failed request.', [ + 'exception' => $captureException, + ]); + + return null; + } + + $request->attributes->set('_laratimecode_id', $id); + + return $id; + } + public function shouldCapture(Request $request, Throwable $exception): bool { if (! (bool) $this->config->get('laratimecode.enabled', false)) { diff --git a/src/Commands/ReplayTimeCodeCommand.php b/src/Commands/ReplayTimeCodeCommand.php index 095616e..1bd62bf 100644 --- a/src/Commands/ReplayTimeCodeCommand.php +++ b/src/Commands/ReplayTimeCodeCommand.php @@ -26,7 +26,7 @@ public function handle(SnapshotRepository $snapshots, SnapshotReplayer $replayer return self::FAILURE; } - $result = $replayer->replay($snapshot, (bool) $this->option('auth')); + $result = $replayer->replay($snapshot, $this->option('auth') ? true : null); $this->components->twoColumnDetail('Duration', number_format($result->durationMs, 2).' ms'); $this->components->twoColumnDetail('Expected exception', $result->expectedException ?? 'none'); $this->components->twoColumnDetail( diff --git a/src/Http/Middleware/CaptureFailures.php b/src/Http/Middleware/CaptureFailures.php index 995e9da..09e9534 100644 --- a/src/Http/Middleware/CaptureFailures.php +++ b/src/Http/Middleware/CaptureFailures.php @@ -9,7 +9,6 @@ use Illuminate\Http\Request; use LaraTimeCode\Capture\CaptureContext; use LaraTimeCode\Capture\FailureRecorder; -use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Response; use Throwable; @@ -19,7 +18,6 @@ public function __construct( private readonly FailureRecorder $recorder, private readonly CaptureContext $context, private readonly Config $config, - private readonly LoggerInterface $logger, ) {} public function handle(Request $request, Closure $next): Response @@ -43,19 +41,17 @@ public function handle(Request $request, Closure $next): Response $this->context->start(); try { - return $next($request); - } catch (Throwable $exception) { - if ($this->recorder->shouldCapture($request, $exception)) { - try { - $id = $this->recorder->capture($request, $exception); - $request->attributes->set('_laratimecode_id', $id); - } catch (Throwable $captureException) { - $this->logger->warning('LaraTimeCode could not capture a failed request.', [ - 'exception' => $captureException, - ]); - } + $response = $next($request); + $rendered = $response->exception ?? null; + + if ($rendered instanceof Throwable) { + $this->recorder->captureIfNeeded($request, $rendered); } + return $response; + } catch (Throwable $exception) { + $this->recorder->captureIfNeeded($request, $exception); + throw $exception; } finally { $this->context->stop(); diff --git a/src/LaraTimeCodeServiceProvider.php b/src/LaraTimeCodeServiceProvider.php index cbd8468..1c98b81 100644 --- a/src/LaraTimeCodeServiceProvider.php +++ b/src/LaraTimeCodeServiceProvider.php @@ -4,13 +4,16 @@ namespace LaraTimeCode; +use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Http\Kernel; use Illuminate\Database\Events\QueryExecuted; use Illuminate\Http\Client\Events\ConnectionFailed; use Illuminate\Http\Client\Events\ResponseReceived; +use Illuminate\Http\Request; use Illuminate\Support\ServiceProvider; use LaraTimeCode\Capture\CaptureContext; +use LaraTimeCode\Capture\FailureRecorder; use LaraTimeCode\Capture\FrameworkEventRecorder; use LaraTimeCode\Commands\DeleteTimeCodeCommand; use LaraTimeCode\Commands\ListTimeCodesCommand; @@ -21,6 +24,8 @@ use LaraTimeCode\Http\Middleware\CaptureFailures; use LaraTimeCode\Redaction\Redactor; use LaraTimeCode\Storage\FileSnapshotRepository; +use Psr\Log\LoggerInterface; +use Throwable; final class LaraTimeCodeServiceProvider extends ServiceProvider { @@ -29,6 +34,7 @@ public function register(): void $this->mergeConfigFrom(__DIR__.'/../config/laratimecode.php', 'laratimecode'); $this->app->scoped(CaptureContext::class, static fn (): CaptureContext => new CaptureContext); + $this->app->scoped(FrameworkEventRecorder::class); $this->app->singleton(Redactor::class, function (): Redactor { return new Redactor( @@ -44,6 +50,7 @@ public function register(): void encrypter: $app->make('encrypter'), directory: (string) config('laratimecode.storage.path'), encrypt: (bool) config('laratimecode.storage.encrypt', true), + logger: $app->make(LoggerInterface::class), ); }); } @@ -83,11 +90,38 @@ public function boot(Dispatcher $events): void } $this->app->booted(function (): void { - $kernel = $this->app->make(Kernel::class); + $this->registerMiddleware(); + $this->registerExceptionHook(); + }); + } - if (method_exists($kernel, 'prependMiddleware')) { - $kernel->prependMiddleware(CaptureFailures::class); - } + private function registerMiddleware(): void + { + if (! $this->app->bound(Kernel::class)) { + return; + } + + $kernel = $this->app->make(Kernel::class); + + if (method_exists($kernel, 'prependMiddleware')) { + $kernel->prependMiddleware(CaptureFailures::class); + } + } + + private function registerExceptionHook(): void + { + if (! $this->app->bound(ExceptionHandler::class)) { + return; + } + + $handler = $this->app->make(ExceptionHandler::class); + + if (! method_exists($handler, 'renderable')) { + return; + } + + $handler->renderable(function (Throwable $exception, Request $request): void { + $this->app->make(FailureRecorder::class)->captureIfNeeded($request, $exception); }); } } diff --git a/src/Redaction/Redactor.php b/src/Redaction/Redactor.php index fe91658..d2f93a8 100644 --- a/src/Redaction/Redactor.php +++ b/src/Redaction/Redactor.php @@ -121,9 +121,11 @@ private function truncate(string $value): string return $value; } - return substr($value, 0, $this->maxStringLength).sprintf( - '\n[TRUNCATED %d BYTES]', - strlen($value) - $this->maxStringLength, + $truncated = mb_strcut($value, 0, $this->maxStringLength, 'UTF-8'); + + return $truncated.sprintf( + "\n[TRUNCATED %d BYTES]", + strlen($value) - strlen($truncated), ); } } diff --git a/src/Storage/FileSnapshotRepository.php b/src/Storage/FileSnapshotRepository.php index 109f180..56f00c7 100644 --- a/src/Storage/FileSnapshotRepository.php +++ b/src/Storage/FileSnapshotRepository.php @@ -5,10 +5,12 @@ namespace LaraTimeCode\Storage; use Illuminate\Contracts\Encryption\Encrypter; +use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Filesystem\Filesystem; use JsonException; use LaraTimeCode\Contracts\SnapshotRepository; use LaraTimeCode\Support\SnapshotId; +use Psr\Log\LoggerInterface; use RuntimeException; final class FileSnapshotRepository implements SnapshotRepository @@ -20,6 +22,7 @@ public function __construct( private readonly Encrypter $encrypter, private readonly string $directory, private readonly bool $encrypt = true, + private readonly ?LoggerInterface $logger = null, ) {} public function save(array $snapshot): string @@ -35,7 +38,8 @@ public function save(array $snapshot): string try { $json = json_encode( $snapshot, - JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE + | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR, ); } catch (JsonException $exception) { throw new RuntimeException('Unable to encode the LaraTimeCode snapshot.', 0, $exception); @@ -94,7 +98,16 @@ public function all(): array $snapshots = []; foreach ($this->files->glob($this->directory.'/*.repro') ?: [] as $path) { - $snapshot = $this->find(pathinfo($path, PATHINFO_FILENAME)); + try { + $snapshot = $this->find(pathinfo($path, PATHINFO_FILENAME)); + } catch (RuntimeException|FileNotFoundException $exception) { + $this->logger?->warning('LaraTimeCode skipped an unreadable snapshot.', [ + 'path' => $path, + 'exception' => $exception, + ]); + + continue; + } if ($snapshot !== null) { $snapshots[] = $snapshot; diff --git a/tests/Feature/CaptureFailureTest.php b/tests/Feature/CaptureFailureTest.php index b1c425f..1ddce6f 100644 --- a/tests/Feature/CaptureFailureTest.php +++ b/tests/Feature/CaptureFailureTest.php @@ -4,9 +4,13 @@ namespace LaraTimeCode\Tests\Feature; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; +use LaraTimeCode\Capture\FailureRecorder; use LaraTimeCode\Contracts\SnapshotRepository; use LaraTimeCode\Replay\SnapshotReplayer; +use LaraTimeCode\Tests\Fixtures\ResponsableFailure; +use LaraTimeCode\Tests\Fixtures\SelfRenderingFailure; use LaraTimeCode\Tests\TestCase; use RuntimeException; @@ -57,6 +61,75 @@ public function test_it_replays_the_original_exception_without_recapturing_it(): self::assertCount(1, $repository->all()); } + public function test_it_captures_a_failed_request_while_exception_handling_is_active(): void + { + $response = $this->postJson('/timecode-test', ['order_id' => 42]); + $snapshots = $this->app->make(SnapshotRepository::class)->all(); + + $response->assertStatus(500); + self::assertCount(1, $snapshots); + self::assertSame(RuntimeException::class, $snapshots[0]['exception']['class']); + self::assertSame(42, $snapshots[0]['request']['input']['order_id']); + } + + public function test_it_captures_exceptions_that_render_themselves(): void + { + $response = $this->get('/timecode-self-rendering-test'); + $snapshots = $this->app->make(SnapshotRepository::class)->all(); + + $response->assertStatus(503); + self::assertCount(1, $snapshots); + self::assertSame(SelfRenderingFailure::class, $snapshots[0]['exception']['class']); + } + + public function test_it_captures_responsable_exceptions(): void + { + $response = $this->get('/timecode-responsable-test'); + $snapshots = $this->app->make(SnapshotRepository::class)->all(); + + $response->assertStatus(502); + self::assertCount(1, $snapshots); + self::assertSame(ResponsableFailure::class, $snapshots[0]['exception']['class']); + } + + public function test_it_captures_a_failure_only_once_when_both_paths_run(): void + { + $recorder = $this->app->make(FailureRecorder::class); + $request = Request::create('/timecode-test', 'POST', ['order_id' => 42]); + $exception = new RuntimeException('The checkout exploded.'); + + $first = $recorder->captureIfNeeded($request, $exception); + $second = $recorder->captureIfNeeded($request, $exception); + + self::assertIsString($first); + self::assertSame($first, $second); + self::assertCount(1, $this->app->make(SnapshotRepository::class)->all()); + } + + public function test_the_response_hook_and_the_handler_hook_agree_on_one_snapshot(): void + { + $this->get('/timecode-db-test'); + + $snapshots = $this->app->make(SnapshotRepository::class)->all(); + + self::assertCount(1, $snapshots); + self::assertSame('select 1 as one', $snapshots[0]['execution']['queries'][0]['sql']); + } + + public function test_the_shipped_defaults_ignore_exceptions_laravel_renders_as_expected_responses(): void + { + $defaults = require dirname(__DIR__, 2).'/config/laratimecode.php'; + $this->app['config']->set( + 'laratimecode.capture.ignore_exceptions', + $defaults['capture']['ignore_exceptions'], + ); + + $response = $this->get('/timecode-not-found-test'); + + $response->assertStatus(404); + self::assertSame([], $this->app->make(SnapshotRepository::class)->all()); + } + public function test_capture_is_disabled_by_default_switch(): void { $this->app['config']->set('laratimecode.enabled', false); diff --git a/tests/Feature/SnapshotReplayerTest.php b/tests/Feature/SnapshotReplayerTest.php new file mode 100644 index 0000000..fb25cb3 --- /dev/null +++ b/tests/Feature/SnapshotReplayerTest.php @@ -0,0 +1,86 @@ +increments('id'); + $table->string('email'); + }); + + ReplayUser::query()->create(['email' => 'person@example.com']); + } + + public function test_it_restores_the_captured_user_from_configuration(): void + { + $this->app['config']->set('laratimecode.replay.restore_auth_user', true); + + $result = $this->app->make(SnapshotReplayer::class)->replay($this->snapshot()); + + self::assertSame('Authenticated as 1', $result->actualException?->getMessage()); + } + + public function test_it_leaves_the_replay_unauthenticated_by_default(): void + { + $result = $this->app->make(SnapshotReplayer::class)->replay($this->snapshot()); + + self::assertSame('Authenticated as guest', $result->actualException?->getMessage()); + } + + public function test_an_explicit_argument_overrides_the_configuration(): void + { + $this->app['config']->set('laratimecode.replay.restore_auth_user', true); + + $result = $this->app->make(SnapshotReplayer::class)->replay($this->snapshot(), false); + + self::assertSame('Authenticated as guest', $result->actualException?->getMessage()); + } + + public function test_the_replay_command_honours_the_configured_auth_restore(): void + { + $this->app['config']->set('laratimecode.replay.restore_auth_user', true); + $this->app->make(SnapshotRepository::class)->save($this->snapshot()); + + $this->artisan('timecode:replay', ['id' => '20260817-120000-123456-abcd1234']) + ->expectsOutputToContain('Authenticated as 1') + ->assertExitCode(Command::SUCCESS); + } + + /** @return array */ + private function snapshot(): array + { + return [ + 'id' => '20260817-120000-123456-abcd1234', + 'captured_at' => '2026-08-17T12:00:00+00:00', + 'request' => [ + 'method' => 'GET', + 'uri' => '/timecode-auth-test', + 'query' => [], + 'input' => [], + 'headers' => [], + ], + 'auth' => [ + 'guard' => 'web', + 'class' => ReplayUser::class, + 'identifier' => 1, + ], + 'exception' => ['class' => RuntimeException::class], + ]; + } +} diff --git a/tests/Fixtures/ReplayUser.php b/tests/Fixtures/ReplayUser.php new file mode 100644 index 0000000..ef642c2 --- /dev/null +++ b/tests/Fixtures/ReplayUser.php @@ -0,0 +1,20 @@ +name('timecode.db-test'); + + Route::get('/timecode-self-rendering-test', static function (): never { + throw new SelfRenderingFailure('The self rendering flow exploded.'); + })->name('timecode.self-rendering-test'); + + Route::get('/timecode-responsable-test', static function (): never { + throw new ResponsableFailure('The responsable flow exploded.'); + })->name('timecode.responsable-test'); + + Route::get('/timecode-not-found-test', static function (): never { + throw (new ModelNotFoundException)->setModel('App\Models\User', [999999]); + })->name('timecode.not-found-test'); + + Route::get('/timecode-auth-test', static function (): never { + throw new RuntimeException('Authenticated as '.(string) (auth()->id() ?? 'guest')); + })->name('timecode.auth-test'); } protected function tearDown(): void diff --git a/tests/Unit/FileSnapshotRepositoryTest.php b/tests/Unit/FileSnapshotRepositoryTest.php index 1e1916e..8edf613 100644 --- a/tests/Unit/FileSnapshotRepositoryTest.php +++ b/tests/Unit/FileSnapshotRepositoryTest.php @@ -8,6 +8,7 @@ use Illuminate\Filesystem\Filesystem; use LaraTimeCode\Storage\FileSnapshotRepository; use PHPUnit\Framework\TestCase; +use Psr\Log\AbstractLogger; final class FileSnapshotRepositoryTest extends TestCase { @@ -50,4 +51,46 @@ public function test_it_stores_and_loads_encrypted_snapshots(): void ); self::assertSame($snapshot, $repository->find($snapshot['id'])); } + + public function test_it_skips_snapshots_it_cannot_read_and_reports_them(): void + { + $files = new Filesystem; + $logger = new class extends AbstractLogger + { + /** @var list */ + public array $warnings = []; + + public function log($level, $message, array $context = []): void + { + if ($level === 'warning') { + $this->warnings[] = (string) ($context['path'] ?? ''); + } + } + }; + + $repository = new FileSnapshotRepository( + $files, + new Encrypter(str_repeat('k', 32), 'AES-256-CBC'), + $this->directory, + true, + $logger, + ); + $repository->save(['id' => 'readable-snapshot', 'captured_at' => '2026-08-17T12:00:00+00:00']); + + $foreign = new FileSnapshotRepository( + $files, + new Encrypter(str_repeat('z', 32), 'AES-256-CBC'), + $this->directory, + true, + ); + $foreign->save(['id' => 'foreign-key-snapshot', 'captured_at' => '2026-08-18T12:00:00+00:00']); + + $files->put($this->directory.'/malformed-snapshot.repro', 'not json at all'); + + $snapshots = $repository->all(); + + self::assertCount(1, $snapshots); + self::assertSame('readable-snapshot', $snapshots[0]['id']); + self::assertCount(2, $logger->warnings); + } } diff --git a/tests/Unit/RedactorTest.php b/tests/Unit/RedactorTest.php index 020109d..7967a1b 100644 --- a/tests/Unit/RedactorTest.php +++ b/tests/Unit/RedactorTest.php @@ -26,6 +26,16 @@ public function test_it_redacts_nested_sensitive_fields(): void self::assertSame('[REDACTED]', $result['headers']['authorization']); } + public function test_it_truncates_multibyte_strings_without_breaking_encoding(): void + { + $redactor = new Redactor([], '[REDACTED]', 9); + $result = $redactor->redact(str_repeat('привет', 4)); + + self::assertSame("прив\n[TRUNCATED 40 BYTES]", $result); + self::assertTrue(mb_check_encoding($result, 'UTF-8')); + self::assertIsString(json_encode($result, JSON_THROW_ON_ERROR)); + } + public function test_it_redacts_query_parameters_in_urls(): void { $redactor = new Redactor(['token']);