diff --git a/src/Security/BpnAuthenticator.php b/src/Security/BpnAuthenticator.php index b93110b..61e3f64 100644 --- a/src/Security/BpnAuthenticator.php +++ b/src/Security/BpnAuthenticator.php @@ -67,7 +67,10 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent public function authenticate(Request $request): Passport { - $email = trim($request->request->getString('_username')); + // One canonical casing per account. BusPro matches an address case-insensitively and so + // does the utf8mb4_unicode_ci column, so the casing somebody happens to type must not + // become the casing every later BusPro call re-sends. + $email = mb_strtolower(trim($request->request->getString('_username'))); $passwordPlain = trim($request->request->getString('_password')); // BPN requires md5, not a real hash @@ -114,6 +117,10 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent $user = $userRepository->findOneBy(['email' => $email]) ?? new User($email); $user + // Not redundant next to the constructor: an account created before the address was + // normalized still carries the casing of its very first login, and everything + // downstream re-sends what is stored rather than what was just typed. + ->setEmail($email) ->setPassword($encryptedPassword) ->setPersonId($personalData->personId) ->setAddressId($personalData->addressId) diff --git a/tests/Security/BpnAuthenticatorTest.php b/tests/Security/BpnAuthenticatorTest.php index 5429db0..65ebf76 100644 --- a/tests/Security/BpnAuthenticatorTest.php +++ b/tests/Security/BpnAuthenticatorTest.php @@ -76,6 +76,35 @@ class BpnAuthenticatorTest extends TestCase self::assertNotNull($user->getLastLoginAt(), 'the rest of the profile is still synced'); } + public function testTheLoginAddressIsNormalizedAndRewritesAStoredCasing(): void + { + // An account from before the address was normalized: it carries the casing of whatever + // its very first login happened to type. + $existing = (new User('Teamer@Example.ORG'))->setRoles([Role::TEAMER]); + + $persisted = null; + $persistedPassword = null; + $lookupCriteria = null; + $authenticator = $this->authenticator( + $this->crmAttributes([Role::TEAMER], []), + $existing, + $persisted, + $persistedPassword, + null, + $lookupCriteria, + ); + + $user = $this->loadUser($authenticator, ' TEAMER@example.org '); + + self::assertSame($existing, $persisted, 'a differently cased address is the same account'); + self::assertSame(['email' => 'teamer@example.org'], $lookupCriteria); + self::assertSame( + 'teamer@example.org', + $user->getEmail(), + 'the stored casing follows the login rather than staying frozen', + ); + } + public function testApprovedRoleSurvivesTheNextLogin(): void { $existing = (new User('manager@example.org'))->setRoles([Role::TEAMER, Role::HOUSE_MANAGER]); @@ -344,6 +373,7 @@ class BpnAuthenticatorTest extends TestCase ?User &$persisted, ?string &$persistedPassword = null, ?string $contactEmail = null, + ?array &$lookupCriteria = null, ): BpnAuthenticator { $personalData = new PersonalData(); $personalData->personId = 42; @@ -355,7 +385,14 @@ class BpnAuthenticatorTest extends TestCase $apiClient->method('getCrmAttributes')->willReturn($crmAttributes); $repository = $this->createStub(EntityRepository::class); - $repository->method('findOneBy')->willReturn($existing); + $repository + ->method('findOneBy') + ->willReturnCallback(static function (array $criteria) use ($existing, &$lookupCriteria): ?User { + $lookupCriteria = $criteria; + + return $existing; + }) + ; $entityManager = $this->createStub(EntityManagerInterface::class); $entityManager->method('getRepository')->willReturn($repository);