From f4cba5ba9e45db1a4270e3d5247941b2ca5bc3e1 Mon Sep 17 00:00:00 2001 From: Leonid Timofeev Date: Fri, 21 Aug 2026 09:25:01 +0900 Subject: [PATCH] Fix failure capture, redaction and snapshot resilience Failed requests were never 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 only reachable under withoutExceptionHandling(), which is exactly what the test suite used. Capture now reads the throwable that Pipeline attaches to the rendered response, with a renderable() hook as a secondary path and deduplication through the request attribute. Other fixes: - timecode:replay passed an explicit false when --auth was absent, so replay.restore_auth_user never applied. - Truncated strings carried a literal \n instead of a newline, and were cut mid-character, producing invalid UTF-8 that made the whole snapshot fail to encode. - timecode:list failed outright when a single snapshot was unreadable, for example after an APP_KEY rotation. Such files are now skipped and reported to the log. - The service provider threw when no HTTP kernel was bound. 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 recorded despite the intent of the defaults. Verified against a fresh Laravel 13.26.1 application on PHP 8.5. --- CHANGELOG.md | 15 ++++ CHANGELOG.ru.md | 15 ++++ composer.json | 8 ++- config/laratimecode.php | 6 ++ src/Capture/FailureRecorder.php | 29 ++++++++ src/Commands/ReplayTimeCodeCommand.php | 2 +- src/Http/Middleware/CaptureFailures.php | 22 +++--- src/LaraTimeCodeServiceProvider.php | 42 +++++++++-- src/Redaction/Redactor.php | 8 ++- src/Storage/FileSnapshotRepository.php | 17 ++++- tests/Feature/CaptureFailureTest.php | 73 +++++++++++++++++++ tests/Feature/SnapshotReplayerTest.php | 86 +++++++++++++++++++++++ tests/Fixtures/ReplayUser.php | 20 ++++++ tests/Fixtures/ResponsableFailure.php | 17 +++++ tests/Fixtures/SelfRenderingFailure.php | 17 +++++ tests/TestCase.php | 19 +++++ tests/Unit/FileSnapshotRepositoryTest.php | 43 ++++++++++++ tests/Unit/RedactorTest.php | 10 +++ 18 files changed, 425 insertions(+), 24 deletions(-) create mode 100644 tests/Feature/SnapshotReplayerTest.php create mode 100644 tests/Fixtures/ReplayUser.php create mode 100644 tests/Fixtures/ResponsableFailure.php create mode 100644 tests/Fixtures/SelfRenderingFailure.php 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']);