Skip to content

Commit 1243de6

Browse files
turegjorupclaude
andcommitted
test(logging): guard identity enrichment + auth logging, cover request-id subscriber
Apply the "logging must never break the request it annotates" principle consistently: - RequestContextProcessor: widen the try/catch from getActiveTenant() to the whole identity block, so screen_id (getScreen()->getId() on an unhydrated ScreenUser throws an uninitialized-typed-property Error) and user_id are guarded too. Fields set before a throw are kept. - AuthLoggingSubscriber: run each handler's logging through a guard() so a failure (context building or write) drops the line instead of aborting authentication, making the class's "only logs, never alters auth" contract real. Tests: - New RequestIdSubscriberTest: adopt/mint/empty-header/no-overwrite/sub-request. - AuthLoggingSubscriberTest: add onLoginSuccess and onJwtFailure (all four match arms: invalid/expired/not_found/unknown). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 35ed9dc commit 1243de6

4 files changed

Lines changed: 213 additions & 21 deletions

File tree

src/Logger/EventSubscriber/AuthLoggingSubscriber.php

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,35 +46,35 @@ public static function getSubscribedEvents(): array
4646

4747
public function onLoginSuccess(LoginSuccessEvent $event): void
4848
{
49-
$this->logger->info('Authentication succeeded', [
49+
$this->guard(fn () => $this->logger->info('Authentication succeeded', [
5050
'event' => 'auth.login_success',
5151
'user_identifier' => $event->getUser()->getUserIdentifier(),
5252
'firewall' => $event->getFirewallName(),
5353
'authenticator' => $event->getAuthenticator()::class,
54-
]);
54+
]));
5555
}
5656

5757
public function onLoginFailure(LoginFailureEvent $event): void
5858
{
59-
$this->logger->warning('Authentication failed', [
59+
$this->guard(fn () => $this->logger->warning('Authentication failed', [
6060
'event' => 'auth.login_failure',
6161
'firewall' => $event->getFirewallName(),
6262
'authenticator' => $event->getAuthenticator()::class,
6363
'exception' => $event->getException(),
64-
]);
64+
]));
6565
}
6666

6767
public function onLogout(LogoutEvent $event): void
6868
{
69-
$this->logger->info('User logged out', [
69+
$this->guard(fn () => $this->logger->info('User logged out', [
7070
'event' => 'auth.logout',
7171
'user_identifier' => $event->getToken()?->getUserIdentifier(),
72-
]);
72+
]));
7373
}
7474

7575
public function onJwtFailure(JWTFailureEventInterface $event): void
7676
{
77-
$this->logger->warning('JWT authentication failed', [
77+
$this->guard(fn () => $this->logger->warning('JWT authentication failed', [
7878
'event' => 'auth.jwt_failure',
7979
'reason' => match (true) {
8080
$event instanceof JWTInvalidEvent => 'invalid',
@@ -83,6 +83,23 @@ public function onJwtFailure(JWTFailureEventInterface $event): void
8383
default => 'unknown',
8484
},
8585
'exception' => $event->getException(),
86-
]);
86+
]));
87+
}
88+
89+
/**
90+
* Runs a logging closure, swallowing any failure.
91+
*
92+
* This subscriber observes authentication events; logging must never break
93+
* them. Both the context building (identity accessors that can throw) and the
94+
* write itself run inside the guard, so a logging failure drops the line
95+
* rather than aborting login/logout.
96+
*/
97+
private function guard(callable $log): void
98+
{
99+
try {
100+
$log();
101+
} catch (\Throwable) {
102+
// Intentionally silent — see method doc.
103+
}
87104
}
88105
}

src/Logger/Processor/RequestContextProcessor.php

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -48,22 +48,27 @@ public function __invoke(LogRecord $record): LogRecord
4848

4949
$user = $this->security->getUser();
5050
if (null !== $user) {
51-
// Screen tokens authenticate as ScreenUser; everything else is a
52-
// back-office User. Populate screen_id XOR user_id accordingly.
53-
if ($user instanceof ScreenUser) {
54-
$record->extra['screen_id'] = (string) $user->getScreen()->getId();
55-
} else {
56-
$record->extra['user_id'] = $user->getUserIdentifier();
57-
}
51+
// Enrichment must never break the request it is annotating. Every
52+
// identity accessor below can throw — getActiveTenant() when no tenant
53+
// is resolved yet, getScreen() on a not-yet-hydrated screen token,
54+
// getUserIdentifier() on a custom user — so the whole block is guarded.
55+
// Fields written before a throw are kept (the record is mutated in
56+
// place); the failing one and any after it are simply left unset.
57+
try {
58+
// Screen tokens authenticate as ScreenUser; everything else is a
59+
// back-office User. Populate screen_id XOR user_id accordingly.
60+
if ($user instanceof ScreenUser) {
61+
$record->extra['screen_id'] = (string) $user->getScreen()->getId();
62+
} else {
63+
$record->extra['user_id'] = $user->getUserIdentifier();
64+
}
5865

59-
if ($user instanceof TenantScopedUserInterface) {
60-
// getActiveTenant() can throw when no tenant is resolved yet;
61-
// enrichment must never break the request it is annotating.
62-
try {
66+
if ($user instanceof TenantScopedUserInterface) {
6367
$record->extra['tenant_id'] = $user->getActiveTenant()->getTenantKey();
64-
} catch (\Throwable) {
65-
// No active tenant on this request — leave tenant_id unset.
6668
}
69+
} catch (\Throwable) {
70+
// An identity accessor failed (no active tenant, unhydrated screen,
71+
// lazy-load error, …). Keep whatever was set; never break logging.
6772
}
6873
}
6974

tests/Logger/EventSubscriber/AuthLoggingSubscriberTest.php

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,92 @@
55
namespace App\Tests\Logger\EventSubscriber;
66

77
use App\Logger\EventSubscriber\AuthLoggingSubscriber;
8+
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTExpiredEvent;
9+
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTFailureEventInterface;
10+
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTInvalidEvent;
11+
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTNotFoundEvent;
812
use Monolog\Handler\TestHandler;
913
use Monolog\Level;
1014
use Monolog\Logger;
1115
use PHPUnit\Framework\TestCase;
1216
use Symfony\Component\HttpFoundation\Request;
1317
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
18+
use Symfony\Component\Security\Core\Exception\AuthenticationException;
1419
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
20+
use Symfony\Component\Security\Core\User\UserInterface;
1521
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
22+
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
23+
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
1624
use Symfony\Component\Security\Http\Event\LoginFailureEvent;
25+
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
1726
use Symfony\Component\Security\Http\Event\LogoutEvent;
1827

1928
class AuthLoggingSubscriberTest extends TestCase
2029
{
30+
public function testLoginSuccessEmitsInfoWithIdentifierFirewallAndAuthenticator(): void
31+
{
32+
$handler = new TestHandler();
33+
$subscriber = new AuthLoggingSubscriber(new Logger('auth', [$handler]));
34+
35+
$user = $this->createMock(UserInterface::class);
36+
$user->method('getUserIdentifier')->willReturn('editor@example.com');
37+
$authenticator = $this->createMock(AuthenticatorInterface::class);
38+
39+
$event = new LoginSuccessEvent(
40+
$authenticator,
41+
new SelfValidatingPassport(new UserBadge('editor@example.com', fn () => $user)),
42+
$this->createMock(TokenInterface::class),
43+
Request::create('/v2/authentication/token', Request::METHOD_POST),
44+
null,
45+
'main',
46+
);
47+
48+
$subscriber->onLoginSuccess($event);
49+
50+
$this->assertTrue($handler->hasInfoRecords());
51+
$record = $handler->getRecords()[0];
52+
$this->assertSame('auth.login_success', $record->context['event']);
53+
$this->assertSame('editor@example.com', $record->context['user_identifier']);
54+
$this->assertSame('main', $record->context['firewall']);
55+
$this->assertSame($authenticator::class, $record->context['authenticator']);
56+
}
57+
58+
/**
59+
* The `reason` string is what operators key dashboards on, and the three
60+
* concrete Lexik events all share JWTFailureEventInterface — an easy arm to
61+
* mis-wire — so pin each mapping, including the `unknown` default.
62+
*/
63+
public function testJwtFailureMapsEachEventTypeToReason(): void
64+
{
65+
$exception = new AuthenticationException('nope');
66+
$cases = [
67+
'invalid' => new JWTInvalidEvent($exception, null),
68+
'expired' => new JWTExpiredEvent($exception, null),
69+
'not_found' => new JWTNotFoundEvent($exception, null),
70+
'unknown' => $this->unknownFailureEvent($exception),
71+
];
72+
73+
foreach ($cases as $expectedReason => $event) {
74+
$handler = new TestHandler();
75+
$subscriber = new AuthLoggingSubscriber(new Logger('auth', [$handler]));
76+
77+
$subscriber->onJwtFailure($event);
78+
79+
$this->assertTrue($handler->hasWarningRecords(), "reason=$expectedReason");
80+
$record = $handler->getRecords()[0];
81+
$this->assertSame('auth.jwt_failure', $record->context['event']);
82+
$this->assertSame($expectedReason, $record->context['reason']);
83+
}
84+
}
85+
86+
private function unknownFailureEvent(AuthenticationException $exception): JWTFailureEventInterface
87+
{
88+
$event = $this->createMock(JWTFailureEventInterface::class);
89+
$event->method('getException')->willReturn($exception);
90+
91+
return $event;
92+
}
93+
2194
public function testLoginFailureEmitsAuthWarningWithoutCredentials(): void
2295
{
2396
$handler = new TestHandler();
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Tests\Logger\EventSubscriber;
6+
7+
use App\Logger\EventSubscriber\RequestIdSubscriber;
8+
use PHPUnit\Framework\TestCase;
9+
use Symfony\Component\HttpFoundation\Request;
10+
use Symfony\Component\HttpFoundation\Response;
11+
use Symfony\Component\HttpKernel\Event\RequestEvent;
12+
use Symfony\Component\HttpKernel\Event\ResponseEvent;
13+
use Symfony\Component\HttpKernel\HttpKernelInterface;
14+
15+
class RequestIdSubscriberTest extends TestCase
16+
{
17+
private const HEADER = 'X-Request-Id';
18+
private const ATTR = '_request_id';
19+
20+
public function testInboundHeaderIsAdoptedAndEchoedOnResponse(): void
21+
{
22+
$subscriber = new RequestIdSubscriber();
23+
$request = Request::create('/');
24+
$request->headers->set(self::HEADER, 'upstream-id-123');
25+
26+
$subscriber->onRequest($this->requestEvent($request));
27+
$this->assertSame('upstream-id-123', $request->attributes->get(self::ATTR));
28+
29+
$response = new Response();
30+
$subscriber->onResponse($this->responseEvent($request, $response));
31+
$this->assertSame('upstream-id-123', $response->headers->get(self::HEADER));
32+
}
33+
34+
public function testIdIsMintedWhenNoInboundHeaderAndEchoedOnResponse(): void
35+
{
36+
$subscriber = new RequestIdSubscriber();
37+
$request = Request::create('/');
38+
39+
$subscriber->onRequest($this->requestEvent($request));
40+
$minted = $request->attributes->get(self::ATTR);
41+
$this->assertIsString($minted);
42+
$this->assertNotSame('', $minted);
43+
44+
$response = new Response();
45+
$subscriber->onResponse($this->responseEvent($request, $response));
46+
$this->assertSame($minted, $response->headers->get(self::HEADER));
47+
}
48+
49+
public function testEmptyInboundHeaderMintsAFreshId(): void
50+
{
51+
$subscriber = new RequestIdSubscriber();
52+
$request = Request::create('/');
53+
$request->headers->set(self::HEADER, '');
54+
55+
$subscriber->onRequest($this->requestEvent($request));
56+
57+
// `?:` treats the empty header as absent and mints a non-empty id.
58+
$this->assertNotSame('', $request->attributes->get(self::ATTR));
59+
}
60+
61+
public function testResponseHeaderIsNotOverwrittenWhenAlreadySet(): void
62+
{
63+
$subscriber = new RequestIdSubscriber();
64+
$request = Request::create('/');
65+
$request->attributes->set(self::ATTR, 'our-id');
66+
67+
$response = new Response();
68+
$response->headers->set(self::HEADER, 'downstream-id');
69+
$subscriber->onResponse($this->responseEvent($request, $response));
70+
71+
$this->assertSame('downstream-id', $response->headers->get(self::HEADER));
72+
}
73+
74+
public function testSubRequestIsIgnored(): void
75+
{
76+
$subscriber = new RequestIdSubscriber();
77+
$request = Request::create('/');
78+
79+
$subscriber->onRequest($this->requestEvent($request, HttpKernelInterface::SUB_REQUEST));
80+
$this->assertNull($request->attributes->get(self::ATTR));
81+
82+
$request->attributes->set(self::ATTR, 'main-id');
83+
$response = new Response();
84+
$subscriber->onResponse($this->responseEvent($request, $response, HttpKernelInterface::SUB_REQUEST));
85+
$this->assertFalse($response->headers->has(self::HEADER));
86+
}
87+
88+
private function requestEvent(Request $request, int $type = HttpKernelInterface::MAIN_REQUEST): RequestEvent
89+
{
90+
return new RequestEvent($this->createMock(HttpKernelInterface::class), $request, $type);
91+
}
92+
93+
private function responseEvent(Request $request, Response $response, int $type = HttpKernelInterface::MAIN_REQUEST): ResponseEvent
94+
{
95+
return new ResponseEvent($this->createMock(HttpKernelInterface::class), $request, $type, $response);
96+
}
97+
}

0 commit comments

Comments
 (0)