Files
crewli/api/tests/Feature/Observability/PiiScrubbingTest.php
bert.hausmans bdb89a2479 feat: sentry-laravel install + scrubber + ignored exceptions
WS-7 PR-2 commit 1. Wires sentry-laravel into the app behind a
config-only no-op when SENTRY_DSN_BACKEND is empty (RFC §3.3).

- composer require sentry/sentry-laravel ^4.15 (resolved 4.25.1)
- config/sentry.php: DSN env mapped to SENTRY_DSN_BACKEND, environment
  falls back to APP_ENV, traces/profiles forced to 0.0 (RFC §2
  amendment B), send_default_pii hard-pinned false, before_send to
  SentryEventScrubber, ignore_exceptions covers ValidationException /
  AuthenticationException / AuthorizationException.
- app/Services/Observability/SentryEventScrubber.php: recursive body /
  header / query-string scrubber + form_values wholesale replacement +
  HttpException sub-500 drop (status filter that ignore_exceptions
  cannot do class-only). Max-depth guard against malicious payloads.
- app/Enums/Observability/ActorType.php: enum + resolver for §3.6
  actor_type tag (consumed by BindSentryContext in commit 2).
- tests/Feature/Observability/PiiScrubbingTest.php: 20 cases.
- api/.env.example: SENTRY_DSN_BACKEND + SENTRY_RELEASE entries.

Larastan: clean. Test count: 1487 to 1507.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:55:50 +02:00

239 lines
8.2 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Observability;
use App\Services\Observability\SentryEventScrubber;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Validation\ValidationException;
use RuntimeException;
use Sentry\Event;
use Sentry\EventHint;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Tests\TestCase;
final class PiiScrubbingTest extends TestCase
{
/**
* @param array<string, mixed> $request
*/
private function scrubEventWithRequest(array $request, ?EventHint $hint = null): ?Event
{
$event = Event::createEvent();
$event->setRequest($request);
return (new SentryEventScrubber)->scrub($event, $hint);
}
public function test_password_in_request_body_is_scrubbed(): void
{
$event = $this->scrubEventWithRequest([
'data' => ['email' => 'a@b.test', 'password' => 'sup3rsecret!'],
]);
$this->assertSame('[scrubbed]', $event->getRequest()['data']['password']);
$this->assertSame('a@b.test', $event->getRequest()['data']['email']);
}
public function test_password_confirmation_is_scrubbed(): void
{
$event = $this->scrubEventWithRequest([
'data' => ['password_confirmation' => 'p@ss', 'current_password' => 'oldpass'],
]);
$this->assertSame('[scrubbed]', $event->getRequest()['data']['password_confirmation']);
$this->assertSame('[scrubbed]', $event->getRequest()['data']['current_password']);
}
public function test_authorization_header_is_scrubbed(): void
{
$event = $this->scrubEventWithRequest([
'headers' => ['Authorization' => 'Bearer abc.def.ghi'],
]);
$this->assertSame('[scrubbed]', $event->getRequest()['headers']['Authorization']);
}
public function test_cookie_header_is_scrubbed(): void
{
$event = $this->scrubEventWithRequest([
'headers' => ['Cookie' => 'crewli_session=abcd'],
]);
$this->assertSame('[scrubbed]', $event->getRequest()['headers']['Cookie']);
}
public function test_x_impersonation_token_header_is_scrubbed(): void
{
$event = $this->scrubEventWithRequest([
'headers' => ['X-Impersonation-Token' => 'imp_token_xyz'],
]);
$this->assertSame('[scrubbed]', $event->getRequest()['headers']['X-Impersonation-Token']);
}
public function test_form_values_payload_is_replaced_wholesale(): void
{
$event = $this->scrubEventWithRequest([
'data' => [
'form_values' => [
'email' => 'sensitive@example.com',
'dietary' => 'vegan',
'phone' => '+31612345678',
],
],
]);
$data = $event->getRequest()['data'];
$this->assertSame('[scrubbed_form_values]', $data['form_values']);
$serialised = json_encode($data, JSON_THROW_ON_ERROR);
$this->assertStringNotContainsString('sensitive@example.com', $serialised);
$this->assertStringNotContainsString('vegan', $serialised);
$this->assertStringNotContainsString('+31612345678', $serialised);
}
public function test_token_query_string_is_scrubbed(): void
{
$event = $this->scrubEventWithRequest([
'query_string' => 'token=abc123&keep=me',
]);
$qs = $event->getRequest()['query_string'];
$this->assertStringContainsString('token=%5Bscrubbed%5D', $qs);
$this->assertStringContainsString('keep=me', $qs);
}
public function test_api_key_query_string_is_scrubbed(): void
{
$event = $this->scrubEventWithRequest([
'query_string' => 'api_key=xyz&page=2',
]);
$qs = $event->getRequest()['query_string'];
$this->assertStringContainsString('api_key=%5Bscrubbed%5D', $qs);
$this->assertStringContainsString('page=2', $qs);
}
public function test_iban_in_nested_body_is_scrubbed(): void
{
$event = $this->scrubEventWithRequest([
'data' => [
'profile' => [
'address' => [
'iban' => 'NL91ABNA0417164300',
'street' => 'Damrak 1',
],
],
],
]);
$address = $event->getRequest()['data']['profile']['address'];
$this->assertSame('[scrubbed]', $address['iban']);
$this->assertSame('Damrak 1', $address['street']);
}
public function test_bsn_in_nested_body_is_scrubbed(): void
{
$event = $this->scrubEventWithRequest([
'data' => ['kyc' => ['passport_number' => 'NX1234567', 'bsn' => '123456789']],
]);
$kyc = $event->getRequest()['data']['kyc'];
$this->assertSame('[scrubbed]', $kyc['passport_number']);
$this->assertSame('[scrubbed]', $kyc['bsn']);
}
public function test_send_default_pii_is_false(): void
{
$this->assertFalse(config('sentry.send_default_pii'));
}
public function test_validation_exception_is_in_ignore_list(): void
{
$this->assertContains(ValidationException::class, config('sentry.ignore_exceptions'));
}
public function test_authentication_exception_is_in_ignore_list(): void
{
$this->assertContains(AuthenticationException::class, config('sentry.ignore_exceptions'));
}
public function test_authorization_exception_is_in_ignore_list(): void
{
$this->assertContains(AuthorizationException::class, config('sentry.ignore_exceptions'));
}
public function test_http_exception_404_is_dropped_by_scrubber(): void
{
$event = Event::createEvent();
$hint = EventHint::fromArray(['exception' => new NotFoundHttpException]);
$this->assertNull((new SentryEventScrubber)->scrub($event, $hint));
}
public function test_http_exception_500_is_captured(): void
{
$event = Event::createEvent();
$hint = EventHint::fromArray(['exception' => new HttpException(500, 'boom')]);
$this->assertNotNull((new SentryEventScrubber)->scrub($event, $hint));
}
public function test_throwable_from_controller_is_captured(): void
{
$event = Event::createEvent();
$hint = EventHint::fromArray(['exception' => new RuntimeException('programmer error')]);
$this->assertNotNull((new SentryEventScrubber)->scrub($event, $hint));
}
public function test_form_values_replacement_blocks_attempts_to_smuggle_pii(): void
{
// form_values is a wholesale replace — even if the payload is deeply
// nested, the entire branch is wiped so individual keys cannot leak.
$event = $this->scrubEventWithRequest([
'data' => [
'submission' => [
'form_values' => [
'medical' => 'celiac',
'children' => [
['name' => 'Bobby', 'allergy' => 'peanuts'],
],
],
],
],
]);
$serialised = json_encode($event->getRequest()['data'], JSON_THROW_ON_ERROR);
$this->assertStringContainsString('[scrubbed_form_values]', $serialised);
$this->assertStringNotContainsString('celiac', $serialised);
$this->assertStringNotContainsString('Bobby', $serialised);
$this->assertStringNotContainsString('peanuts', $serialised);
}
public function test_cookies_request_field_is_replaced(): void
{
$event = $this->scrubEventWithRequest([
'cookies' => ['SESSION' => 'abcd', 'tracking' => 'xyz'],
]);
$this->assertSame('[scrubbed]', $event->getRequest()['cookies']);
}
public function test_max_depth_guard_prevents_unbounded_recursion(): void
{
$deep = ['v' => 'leaf'];
for ($i = 0; $i < 15; $i++) {
$deep = ['nest' => $deep];
}
$event = $this->scrubEventWithRequest(['data' => $deep]);
$serialised = json_encode($event->getRequest()['data'], JSON_THROW_ON_ERROR);
$this->assertStringContainsString('[max_depth]', $serialised);
}
}