From 807870b185522f1060070aaf2b98d9d0de1bd6e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Kie=C3=9Fling?= Date: Mon, 23 Feb 2026 08:22:58 +0000 Subject: [PATCH 1/5] feat: add localized random sign-in greetings Add a login-success listener that flashes a random greeting translation key so users see a one-time localized nerd-humor greeting on the first page after authentication. Include provider, listener, and functional/unit tests to verify key selection, event handling, and flash consumption behavior. --- .../Security/FunnyGreetingProvider.php | 30 +++++++ .../LoginSuccessFunnyGreetingListener.php | 34 ++++++++ .../templates/base_appshell.html.twig | 6 +- tests/Application/Account/SignInTest.php | 63 +++++++++++++++ .../Security/FunnyGreetingProviderTest.php | 34 ++++++++ .../LoginSuccessFunnyGreetingListenerTest.php | 81 +++++++++++++++++++ translations/messages.de.yaml | 9 +++ translations/messages.en.yaml | 9 +++ 8 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 src/Account/Infrastructure/Security/FunnyGreetingProvider.php create mode 100644 src/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListener.php create mode 100644 tests/Unit/Account/Infrastructure/Security/FunnyGreetingProviderTest.php create mode 100644 tests/Unit/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListenerTest.php diff --git a/src/Account/Infrastructure/Security/FunnyGreetingProvider.php b/src/Account/Infrastructure/Security/FunnyGreetingProvider.php new file mode 100644 index 00000000..668edc33 --- /dev/null +++ b/src/Account/Infrastructure/Security/FunnyGreetingProvider.php @@ -0,0 +1,30 @@ + + */ + public function getAvailableGreetingKeys(): array + { + return [ + 'auth.greeting.1', + 'auth.greeting.2', + 'auth.greeting.3', + 'auth.greeting.4', + 'auth.greeting.5', + ]; + } + + public function getRandomGreetingKey(): string + { + $greetingKeys = $this->getAvailableGreetingKeys(); + $keyIndex = random_int(0, count($greetingKeys) - 1); + + return $greetingKeys[$keyIndex]; + } +} diff --git a/src/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListener.php b/src/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListener.php new file mode 100644 index 00000000..d0b05e08 --- /dev/null +++ b/src/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListener.php @@ -0,0 +1,34 @@ +getFirewallName() !== 'main' || $event->getPreviousToken() !== null) { + return; + } + + $request = $event->getRequest(); + if (!$request->hasSession()) { + return; + } + + $request + ->getSession() + ->getFlashBag() + ->add('auth_greeting', $this->funnyGreetingProvider->getRandomGreetingKey()); + } +} diff --git a/src/Common/Presentation/Resources/templates/base_appshell.html.twig b/src/Common/Presentation/Resources/templates/base_appshell.html.twig index c04fc0b4..8aa667c4 100644 --- a/src/Common/Presentation/Resources/templates/base_appshell.html.twig +++ b/src/Common/Presentation/Resources/templates/base_appshell.html.twig @@ -49,7 +49,11 @@ {% set alert_classes = alert_classes ~ ' bg-blue-50 border-blue-300 dark:bg-blue-900/30 dark:border-blue-700/50 text-blue-800 dark:text-blue-200' %} {% endif %} {% endfor %} {% endfor %} diff --git a/tests/Application/Account/SignInTest.php b/tests/Application/Account/SignInTest.php index 55d55245..0ced0270 100644 --- a/tests/Application/Account/SignInTest.php +++ b/tests/Application/Account/SignInTest.php @@ -16,6 +16,21 @@ */ final class SignInTest extends WebTestCase { + private const array EN_GREETINGS = [ + 'Welcome back! Your merge conflicts missed you.', + 'Authentication successful. Coffee level: production-ready.', + 'You are now logged in. May your tests stay green.', + 'Access granted. Your stack traces are on vacation.', + 'Session established. The CI pipeline salutes you.', + ]; + private const array DE_GREETINGS = [ + 'Willkommen zurueck! Deine Merge-Konflikte haben dich vermisst.', + 'Authentifizierung erfolgreich. Kaffeelevel: produktionsreif.', + 'Du bist jetzt eingeloggt. Moegen deine Tests gruen bleiben.', + 'Zugriff gewaehrt. Deine Stacktraces machen gerade Urlaub.', + 'Sitzung hergestellt. Die CI-Pipeline gruesst dich.', + ]; + private KernelBrowser $client; private AccountDomainService $accountDomainService; @@ -54,6 +69,12 @@ public function testSignInWithValidCredentialsRedirectsToProjects(): void $this->client->followRedirect(); self::assertResponseIsSuccessful(); self::assertSelectorTextContains('h1', 'Your projects'); + $this->assertGreetingOccurrences(self::EN_GREETINGS, 1); + + // Flash should only be shown on the first page after login. + $this->client->request('GET', '/en/projects'); + self::assertResponseIsSuccessful(); + $this->assertGreetingOccurrences(self::EN_GREETINGS, 0); } public function testSignInWithInvalidCredentialsShowsError(): void @@ -95,6 +116,48 @@ public function testSignInFormHasCorrectFieldNames(): void self::assertCount(0, $crawler->filter('input[name="_password"]')); } + public function testSignInShowsLocalizedGreetingInGerman(): void + { + // Arrange: Create a test user + $email = 'test-signin-de-' . uniqid() . '@example.com'; + $plainPassword = 'test-password-123'; + + $this->createTestUser($email, $plainPassword); + + // Act: Submit the German login form + $crawler = $this->client->request('GET', '/de/account/sign-in'); + + $form = $crawler->selectButton('Weiter')->form([ + 'email' => $email, + 'password' => $plainPassword, + ]); + + $this->client->submit($form); + + // Assert: Redirect and localized greeting on the first rendered page. + self::assertResponseRedirects('/de/projects'); + $this->client->followRedirect(); + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('h1', 'Ihre Projekte'); + $this->assertGreetingOccurrences(self::DE_GREETINGS, 1); + } + + /** + * @param list $expectedGreetings + */ + private function assertGreetingOccurrences(array $expectedGreetings, int $expectedOccurrences): void + { + $responseContent = $this->client->getResponse()->getContent(); + self::assertIsString($responseContent); + + $actualOccurrences = 0; + foreach ($expectedGreetings as $expectedGreeting) { + $actualOccurrences += substr_count($responseContent, $expectedGreeting); + } + + self::assertSame($expectedOccurrences, $actualOccurrences); + } + private function createTestUser(string $email, string $plainPassword): void { // Use proper registration to trigger organization creation via event diff --git a/tests/Unit/Account/Infrastructure/Security/FunnyGreetingProviderTest.php b/tests/Unit/Account/Infrastructure/Security/FunnyGreetingProviderTest.php new file mode 100644 index 00000000..12be50ee --- /dev/null +++ b/tests/Unit/Account/Infrastructure/Security/FunnyGreetingProviderTest.php @@ -0,0 +1,34 @@ +getAvailableGreetingKeys()); + } + + public function testGetRandomGreetingKeyAlwaysReturnsConfiguredKey(): void + { + $provider = new FunnyGreetingProvider(); + $availableKeys = $provider->getAvailableGreetingKeys(); + + for ($iteration = 0; $iteration < 100; ++$iteration) { + self::assertContains($provider->getRandomGreetingKey(), $availableKeys); + } + } +} diff --git a/tests/Unit/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListenerTest.php b/tests/Unit/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListenerTest.php new file mode 100644 index 00000000..fc3983da --- /dev/null +++ b/tests/Unit/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListenerTest.php @@ -0,0 +1,81 @@ +setSession(new Session(new MockArraySessionStorage())); + + $event = $this->createEvent($request); + $listener->handle($event); + + $flashMessages = $request->getSession()->getFlashBag()->get('auth_greeting'); + self::assertCount(1, $flashMessages); + self::assertContains($flashMessages[0], $provider->getAvailableGreetingKeys()); + } + + public function testHandleSkipsWhenPreviousTokenExists(): void + { + $provider = new FunnyGreetingProvider(); + $listener = new LoginSuccessFunnyGreetingListener($provider); + $request = new Request(); + $request->setSession(new Session(new MockArraySessionStorage())); + + $previousToken = $this->createMock(TokenInterface::class); + $event = $this->createEvent($request, previousToken: $previousToken); + $listener->handle($event); + + self::assertSame([], $request->getSession()->getFlashBag()->get('auth_greeting')); + } + + public function testHandleSkipsWhenRequestHasNoSession(): void + { + $provider = new FunnyGreetingProvider(); + $listener = new LoginSuccessFunnyGreetingListener($provider); + $request = new Request(); + + $event = $this->createEvent($request); + $listener->handle($event); + + self::assertFalse($request->hasSession()); + } + + private function createEvent( + Request $request, + string $firewallName = 'main', + ?TokenInterface $previousToken = null, + ): LoginSuccessEvent { + $authenticator = $this->createMock(AuthenticatorInterface::class); + $passport = new SelfValidatingPassport(new UserBadge('test@example.com')); + $authenticatedUser = $this->createMock(TokenInterface::class); + + return new LoginSuccessEvent( + $authenticator, + $passport, + $authenticatedUser, + $request, + null, + $firewallName, + $previousToken + ); + } +} diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml index 15df9bc0..29c7567e 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -360,6 +360,15 @@ workflow_docs: manual_2: "SiteBuilder benachrichtigen, wenn ein PR zusammengeführt wurde (Reviewer-Aktion)" manual_3: "Verwaiste PRs nach Arbeitsbereich-Reset schließen" +# Authentifizierungsgruesse +auth: + greeting: + 1: "Willkommen zurueck! Deine Merge-Konflikte haben dich vermisst." + 2: "Authentifizierung erfolgreich. Kaffeelevel: produktionsreif." + 3: "Du bist jetzt eingeloggt. Moegen deine Tests gruen bleiben." + 4: "Zugriff gewaehrt. Deine Stacktraces machen gerade Urlaub." + 5: "Sitzung hergestellt. Die CI-Pipeline gruesst dich." + # Flash messages flash: error: diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index b1332b40..63e836bb 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -360,6 +360,15 @@ workflow_docs: manual_2: "Notifying SiteBuilder when a PR is merged (reviewer action)" manual_3: "Closing orphaned PRs after workspace reset" +# Authentication greetings +auth: + greeting: + 1: "Welcome back! Your merge conflicts missed you." + 2: "Authentication successful. Coffee level: production-ready." + 3: "You are now logged in. May your tests stay green." + 4: "Access granted. Your stack traces are on vacation." + 5: "Session established. The CI pipeline salutes you." + # Flash messages flash: error: From 19846ed4389eab64d792ee96a7cbb86dedb0d12d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Kie=C3=9Fling?= Date: Mon, 23 Feb 2026 09:25:16 +0000 Subject: [PATCH 2/5] fix: resolve quality errors in sign-in greeting flow Tighten session and greeting key typing so the login greeting listener and tests satisfy PHPStan and project standards, restoring a passing quality pipeline for the feature branch. --- .../Security/FunnyGreetingProvider.php | 2 +- .../Security/LoginSuccessFunnyGreetingListener.php | 11 +++++++---- .../LoginSuccessFunnyGreetingListenerTest.php | 12 +++++++----- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/Account/Infrastructure/Security/FunnyGreetingProvider.php b/src/Account/Infrastructure/Security/FunnyGreetingProvider.php index 668edc33..af21f530 100644 --- a/src/Account/Infrastructure/Security/FunnyGreetingProvider.php +++ b/src/Account/Infrastructure/Security/FunnyGreetingProvider.php @@ -7,7 +7,7 @@ final class FunnyGreetingProvider { /** - * @return list + * @return non-empty-list */ public function getAvailableGreetingKeys(): array { diff --git a/src/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListener.php b/src/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListener.php index d0b05e08..5d53d0e9 100644 --- a/src/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListener.php +++ b/src/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListener.php @@ -5,6 +5,7 @@ namespace App\Account\Infrastructure\Security; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; +use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface; use Symfony\Component\Security\Http\Event\LoginSuccessEvent; #[AsEventListener(event: LoginSuccessEvent::class, method: 'handle')] @@ -26,9 +27,11 @@ public function handle(LoginSuccessEvent $event): void return; } - $request - ->getSession() - ->getFlashBag() - ->add('auth_greeting', $this->funnyGreetingProvider->getRandomGreetingKey()); + $session = $request->getSession(); + if (!$session instanceof FlashBagAwareSessionInterface) { + return; + } + + $session->getFlashBag()->add('auth_greeting', $this->funnyGreetingProvider->getRandomGreetingKey()); } } diff --git a/tests/Unit/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListenerTest.php b/tests/Unit/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListenerTest.php index fc3983da..c7c4fda0 100644 --- a/tests/Unit/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListenerTest.php +++ b/tests/Unit/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListenerTest.php @@ -23,12 +23,13 @@ public function testHandleAddsAuthGreetingFlashForMainFirewall(): void $provider = new FunnyGreetingProvider(); $listener = new LoginSuccessFunnyGreetingListener($provider); $request = new Request(); - $request->setSession(new Session(new MockArraySessionStorage())); + $session = new Session(new MockArraySessionStorage()); + $request->setSession($session); $event = $this->createEvent($request); $listener->handle($event); - $flashMessages = $request->getSession()->getFlashBag()->get('auth_greeting'); + $flashMessages = $session->getFlashBag()->get('auth_greeting'); self::assertCount(1, $flashMessages); self::assertContains($flashMessages[0], $provider->getAvailableGreetingKeys()); } @@ -38,13 +39,14 @@ public function testHandleSkipsWhenPreviousTokenExists(): void $provider = new FunnyGreetingProvider(); $listener = new LoginSuccessFunnyGreetingListener($provider); $request = new Request(); - $request->setSession(new Session(new MockArraySessionStorage())); + $session = new Session(new MockArraySessionStorage()); + $request->setSession($session); $previousToken = $this->createMock(TokenInterface::class); - $event = $this->createEvent($request, previousToken: $previousToken); + $event = $this->createEvent($request, 'main', $previousToken); $listener->handle($event); - self::assertSame([], $request->getSession()->getFlashBag()->get('auth_greeting')); + self::assertSame([], $session->getFlashBag()->get('auth_greeting')); } public function testHandleSkipsWhenRequestHasNoSession(): void From 83f64700f996030804ff35023cd1926ef3b309dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Kie=C3=9Fling?= Date: Mon, 23 Feb 2026 10:37:51 +0000 Subject: [PATCH 3/5] refactor: harden sign-in greeting flow and reduce test brittleness Centralize greeting metadata and skip non-redirect login responses so flashes stay scoped to browser navigation flows. Derive functional greeting assertions from provider keys and translations to keep tests aligned with localized content changes. --- .../Security/FunnyGreetingProvider.php | 23 +++++--- .../LoginSuccessFunnyGreetingListener.php | 12 +++- tests/Application/Account/SignInTest.php | 55 +++++++++++-------- .../LoginSuccessFunnyGreetingListenerTest.php | 37 ++++++++++++- 4 files changed, 91 insertions(+), 36 deletions(-) diff --git a/src/Account/Infrastructure/Security/FunnyGreetingProvider.php b/src/Account/Infrastructure/Security/FunnyGreetingProvider.php index af21f530..1f51a267 100644 --- a/src/Account/Infrastructure/Security/FunnyGreetingProvider.php +++ b/src/Account/Infrastructure/Security/FunnyGreetingProvider.php @@ -6,18 +6,25 @@ final class FunnyGreetingProvider { + public const string FLASH_TYPE = 'auth_greeting'; + + /** + * @var non-empty-list + */ + private const array GREETING_KEYS = [ + 'auth.greeting.1', + 'auth.greeting.2', + 'auth.greeting.3', + 'auth.greeting.4', + 'auth.greeting.5', + ]; + /** - * @return non-empty-list + * @return non-empty-list */ public function getAvailableGreetingKeys(): array { - return [ - 'auth.greeting.1', - 'auth.greeting.2', - 'auth.greeting.3', - 'auth.greeting.4', - 'auth.greeting.5', - ]; + return self::GREETING_KEYS; } public function getRandomGreetingKey(): string diff --git a/src/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListener.php b/src/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListener.php index 5d53d0e9..7003c4c5 100644 --- a/src/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListener.php +++ b/src/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListener.php @@ -5,12 +5,15 @@ namespace App\Account\Infrastructure\Security; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; +use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface; use Symfony\Component\Security\Http\Event\LoginSuccessEvent; #[AsEventListener(event: LoginSuccessEvent::class, method: 'handle')] final readonly class LoginSuccessFunnyGreetingListener { + private const string MAIN_FIREWALL_NAME = 'main'; + public function __construct( private FunnyGreetingProvider $funnyGreetingProvider, ) { @@ -18,7 +21,12 @@ public function __construct( public function handle(LoginSuccessEvent $event): void { - if ($event->getFirewallName() !== 'main' || $event->getPreviousToken() !== null) { + if ($event->getFirewallName() !== self::MAIN_FIREWALL_NAME || $event->getPreviousToken() !== null) { + return; + } + + $response = $event->getResponse(); + if ($response !== null && !$response instanceof RedirectResponse) { return; } @@ -32,6 +40,6 @@ public function handle(LoginSuccessEvent $event): void return; } - $session->getFlashBag()->add('auth_greeting', $this->funnyGreetingProvider->getRandomGreetingKey()); + $session->getFlashBag()->add(FunnyGreetingProvider::FLASH_TYPE, $this->funnyGreetingProvider->getRandomGreetingKey()); } } diff --git a/tests/Application/Account/SignInTest.php b/tests/Application/Account/SignInTest.php index 0ced0270..a3b5186d 100644 --- a/tests/Application/Account/SignInTest.php +++ b/tests/Application/Account/SignInTest.php @@ -5,8 +5,10 @@ namespace App\Tests\Application\Account; use App\Account\Domain\Service\AccountDomainService; +use App\Account\Infrastructure\Security\FunnyGreetingProvider; use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Contracts\Translation\TranslatorInterface; /** * Tests the sign-in flow to prevent regressions in form field configuration. @@ -16,23 +18,10 @@ */ final class SignInTest extends WebTestCase { - private const array EN_GREETINGS = [ - 'Welcome back! Your merge conflicts missed you.', - 'Authentication successful. Coffee level: production-ready.', - 'You are now logged in. May your tests stay green.', - 'Access granted. Your stack traces are on vacation.', - 'Session established. The CI pipeline salutes you.', - ]; - private const array DE_GREETINGS = [ - 'Willkommen zurueck! Deine Merge-Konflikte haben dich vermisst.', - 'Authentifizierung erfolgreich. Kaffeelevel: produktionsreif.', - 'Du bist jetzt eingeloggt. Moegen deine Tests gruen bleiben.', - 'Zugriff gewaehrt. Deine Stacktraces machen gerade Urlaub.', - 'Sitzung hergestellt. Die CI-Pipeline gruesst dich.', - ]; - private KernelBrowser $client; private AccountDomainService $accountDomainService; + private FunnyGreetingProvider $funnyGreetingProvider; + private TranslatorInterface $translator; protected function setUp(): void { @@ -42,6 +31,14 @@ protected function setUp(): void /** @var AccountDomainService $accountDomainService */ $accountDomainService = $container->get(AccountDomainService::class); $this->accountDomainService = $accountDomainService; + + /** @var FunnyGreetingProvider $funnyGreetingProvider */ + $funnyGreetingProvider = $container->get(FunnyGreetingProvider::class); + $this->funnyGreetingProvider = $funnyGreetingProvider; + + /** @var TranslatorInterface $translator */ + $translator = $container->get(TranslatorInterface::class); + $this->translator = $translator; } public function testSignInWithValidCredentialsRedirectsToProjects(): void @@ -69,12 +66,12 @@ public function testSignInWithValidCredentialsRedirectsToProjects(): void $this->client->followRedirect(); self::assertResponseIsSuccessful(); self::assertSelectorTextContains('h1', 'Your projects'); - $this->assertGreetingOccurrences(self::EN_GREETINGS, 1); + $this->assertGreetingOccurrences('en', 1); // Flash should only be shown on the first page after login. $this->client->request('GET', '/en/projects'); self::assertResponseIsSuccessful(); - $this->assertGreetingOccurrences(self::EN_GREETINGS, 0); + $this->assertGreetingOccurrences('en', 0); } public function testSignInWithInvalidCredentialsShowsError(): void @@ -139,15 +136,13 @@ public function testSignInShowsLocalizedGreetingInGerman(): void $this->client->followRedirect(); self::assertResponseIsSuccessful(); self::assertSelectorTextContains('h1', 'Ihre Projekte'); - $this->assertGreetingOccurrences(self::DE_GREETINGS, 1); + $this->assertGreetingOccurrences('de', 1); } - /** - * @param list $expectedGreetings - */ - private function assertGreetingOccurrences(array $expectedGreetings, int $expectedOccurrences): void + private function assertGreetingOccurrences(string $locale, int $expectedOccurrences): void { - $responseContent = $this->client->getResponse()->getContent(); + $expectedGreetings = $this->getLocalizedGreetings($locale); + $responseContent = $this->client->getResponse()->getContent(); self::assertIsString($responseContent); $actualOccurrences = 0; @@ -158,6 +153,20 @@ private function assertGreetingOccurrences(array $expectedGreetings, int $expect self::assertSame($expectedOccurrences, $actualOccurrences); } + /** + * @return list + */ + private function getLocalizedGreetings(string $locale): array + { + $greetingKeys = $this->funnyGreetingProvider->getAvailableGreetingKeys(); + $localizedGreetings = []; + foreach ($greetingKeys as $greetingKey) { + $localizedGreetings[] = $this->translator->trans($greetingKey, [], null, $locale); + } + + return $localizedGreetings; + } + private function createTestUser(string $email, string $plainPassword): void { // Use proper registration to trigger organization creation via event diff --git a/tests/Unit/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListenerTest.php b/tests/Unit/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListenerTest.php index c7c4fda0..0656eeb9 100644 --- a/tests/Unit/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListenerTest.php +++ b/tests/Unit/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListenerTest.php @@ -7,7 +7,9 @@ use App\Account\Infrastructure\Security\FunnyGreetingProvider; use App\Account\Infrastructure\Security\LoginSuccessFunnyGreetingListener; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; @@ -29,7 +31,7 @@ public function testHandleAddsAuthGreetingFlashForMainFirewall(): void $event = $this->createEvent($request); $listener->handle($event); - $flashMessages = $session->getFlashBag()->get('auth_greeting'); + $flashMessages = $session->getFlashBag()->get(FunnyGreetingProvider::FLASH_TYPE); self::assertCount(1, $flashMessages); self::assertContains($flashMessages[0], $provider->getAvailableGreetingKeys()); } @@ -46,7 +48,35 @@ public function testHandleSkipsWhenPreviousTokenExists(): void $event = $this->createEvent($request, 'main', $previousToken); $listener->handle($event); - self::assertSame([], $session->getFlashBag()->get('auth_greeting')); + self::assertSame([], $session->getFlashBag()->get(FunnyGreetingProvider::FLASH_TYPE)); + } + + public function testHandleSkipsForNonMainFirewall(): void + { + $provider = new FunnyGreetingProvider(); + $listener = new LoginSuccessFunnyGreetingListener($provider); + $request = new Request(); + $session = new Session(new MockArraySessionStorage()); + $request->setSession($session); + + $event = $this->createEvent($request, 'admin'); + $listener->handle($event); + + self::assertSame([], $session->getFlashBag()->get(FunnyGreetingProvider::FLASH_TYPE)); + } + + public function testHandleSkipsForNonRedirectResponse(): void + { + $provider = new FunnyGreetingProvider(); + $listener = new LoginSuccessFunnyGreetingListener($provider); + $request = new Request(); + $session = new Session(new MockArraySessionStorage()); + $request->setSession($session); + + $event = $this->createEvent($request, 'main', null, new JsonResponse(['status' => 'ok'])); + $listener->handle($event); + + self::assertSame([], $session->getFlashBag()->get(FunnyGreetingProvider::FLASH_TYPE)); } public function testHandleSkipsWhenRequestHasNoSession(): void @@ -65,6 +95,7 @@ private function createEvent( Request $request, string $firewallName = 'main', ?TokenInterface $previousToken = null, + ?Response $response = null, ): LoginSuccessEvent { $authenticator = $this->createMock(AuthenticatorInterface::class); $passport = new SelfValidatingPassport(new UserBadge('test@example.com')); @@ -75,7 +106,7 @@ private function createEvent( $passport, $authenticatedUser, $request, - null, + $response, $firewallName, $previousToken ); From d39ac515ee66dab5bd00008ca5a04fb906cc468b Mon Sep 17 00:00:00 2001 From: JOBOO! ProductBuilder <263154657+productbuilder-joboo@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:16:27 +0000 Subject: [PATCH 4/5] refactor: harden sign-in greeting handling and document feature behavior Guard greeting flashes to HTML login contexts only, make greeting assertions more robust via typed flash metadata, and document the full post-sign-in greeting flow for maintainability. --- docs/securitybook.md | 36 +++++++++++++++++++ .../LoginSuccessFunnyGreetingListener.php | 19 +++++++++- .../templates/base_appshell.html.twig | 2 +- tests/Application/Account/SignInTest.php | 21 +++++++---- .../LoginSuccessFunnyGreetingListenerTest.php | 28 +++++++++++++++ 5 files changed, 98 insertions(+), 8 deletions(-) diff --git a/docs/securitybook.md b/docs/securitybook.md index 914b891e..afcb4521 100644 --- a/docs/securitybook.md +++ b/docs/securitybook.md @@ -30,6 +30,42 @@ providers: `SecurityUserProvider` also implements `PasswordUpgraderInterface`, so Symfony can transparently rehash passwords when the hashing algorithm changes -- without any controller involvement. +## Post-sign-in greeting flash messages + +After a successful authentication on the `main` firewall, the app shows one localized nerd-humor greeting as a one-time flash message on the first rendered page. + +### Why this exists + +- Provides lightweight positive feedback that sign-in succeeded. +- Keeps behavior centralized in one security hook instead of spreading it across controllers. + +### Implementation details + +1. `LoginSuccessFunnyGreetingListener` (`src/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListener.php`) listens to `LoginSuccessEvent`. +2. `FunnyGreetingProvider` (`src/Account/Infrastructure/Security/FunnyGreetingProvider.php`) owns the allowed translation keys and returns one random key per login. +3. The listener stores the **translation key** in the flash bag under type `auth_greeting`. +4. `base_appshell.html.twig` (`src/Common/Presentation/Resources/templates/base_appshell.html.twig`) renders that flash and translates it with Twig `trans`. + +### Guardrails + +The listener intentionally skips adding a greeting when: + +- the login is not on the `main` firewall, +- there is a previous token (to avoid duplicate flashes from token refresh flows), +- a non-redirect custom response is already set, +- there is no session/flash bag available, +- the request is non-HTML (for example JSON/AJAX contexts). + +### Translation keys + +The greeting texts live in: + +- `translations/messages.en.yaml` under `auth.greeting.1` ... `auth.greeting.5` +- `translations/messages.de.yaml` under `auth.greeting.1` ... `auth.greeting.5` + +Because the flash stores keys (not pre-translated strings), rendering uses the active locale of the page shown after sign-in. + + ## CSRF Protection: Stateless Tokens The application uses **stateless CSRF tokens** (Symfony 7.2+), not the traditional session-bound tokens. This is a fundamentally different protection model. diff --git a/src/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListener.php b/src/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListener.php index 7003c4c5..d6318889 100644 --- a/src/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListener.php +++ b/src/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListener.php @@ -6,6 +6,7 @@ use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface; use Symfony\Component\Security\Http\Event\LoginSuccessEvent; @@ -31,7 +32,7 @@ public function handle(LoginSuccessEvent $event): void } $request = $event->getRequest(); - if (!$request->hasSession()) { + if (!$this->isHtmlRequest($request) || !$request->hasSession()) { return; } @@ -42,4 +43,20 @@ public function handle(LoginSuccessEvent $event): void $session->getFlashBag()->add(FunnyGreetingProvider::FLASH_TYPE, $this->funnyGreetingProvider->getRandomGreetingKey()); } + + private function isHtmlRequest(Request $request): bool + { + if ($request->isXmlHttpRequest()) { + return false; + } + + $requestFormat = $request->getRequestFormat(''); + if ($requestFormat !== '' && $requestFormat !== 'html') { + return false; + } + + $preferredFormat = $request->getPreferredFormat(''); + + return $preferredFormat === '' || $preferredFormat === 'html'; + } } diff --git a/src/Common/Presentation/Resources/templates/base_appshell.html.twig b/src/Common/Presentation/Resources/templates/base_appshell.html.twig index 8aa667c4..8ccad4ee 100644 --- a/src/Common/Presentation/Resources/templates/base_appshell.html.twig +++ b/src/Common/Presentation/Resources/templates/base_appshell.html.twig @@ -48,7 +48,7 @@ {% else %} {% set alert_classes = alert_classes ~ ' bg-blue-50 border-blue-300 dark:bg-blue-900/30 dark:border-blue-700/50 text-blue-800 dark:text-blue-200' %} {% endif %} -