Compare commits

...
2 Commits
Author SHA1 Message Date
frommeandClaude Opus 5 f080d05dd6 fix: normalize the login email case
The login address was only trimmed, so the casing somebody happened to type at
their very first login was frozen into the user row forever — createOrUpdateLocalUser()
never wrote the address back. Everything downstream re-sends the stored address
rather than the one just authenticated with, which also leaked that casing into the
OAuth2 email claim and the log identities.

Harmless in practice, since BusPro matches an address case-insensitively and so does
the utf8mb4_unicode_ci column, but it left User out of step with the newsletter
entities, which have always normalized.

Fold the case once in authenticate(), which covers the BusPro calls, the lookup and a
new account alike, and write the address back on every login so an account created
before this converges instead of staying frozen. No backfill: a row nobody logs into
again is matched case-insensitively either way.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-21 10:03:06 +02:00
fromme 00637042e2 chore: adjust tests to changed role labels 2026-09-21 10:02:29 +02:00
5 changed files with 50 additions and 6 deletions

No files matched your search

+8 -1
View File
@@ -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)
@@ -21,7 +21,7 @@ class ShowControllerTest extends TestCase
$controller->index($this->nominatedUser(), $this->permissionsRequest());
self::assertSame(
[Role::HOUSE_MANAGER => 'Hausleitung', Role::GROUPS_ADMIN => 'Preisrechner Admin'],
[Role::HOUSE_MANAGER => 'Hausleitung', Role::GROUPS_ADMIN => 'GRO Admin'],
$controller->parameters['approvableRoles'],
);
self::assertSame([], $controller->parameters['selfRefusedRoles']);
@@ -36,7 +36,7 @@ class ShowControllerTest extends TestCase
$controller->index($user, $this->permissionsRequest());
// ROLE_ADMIN needs a second administrator, so no button leads into an access denied page.
self::assertSame([Role::MANAGER => 'Manager:in'], $controller->parameters['approvableRoles']);
self::assertSame([Role::MANAGER => 'Reisemanager:in'], $controller->parameters['approvableRoles']);
self::assertSame([Role::ADMIN => 'Administration'], $controller->parameters['selfRefusedRoles']);
}
@@ -18,7 +18,7 @@ class UserFilterDtoTest extends TestCase
$chip = $filter->activeFilters()[0];
self::assertSame('Rolle', $chip->label);
self::assertSame('Preisrechner Admin', $chip->value);
self::assertSame('GRO Admin', $chip->value);
self::assertSame(['role'], $chip->removeKeys);
}
+38 -1
View File
@@ -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('[email protected]'))->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, ' [email protected] ');
self::assertSame($existing, $persisted, 'a differently cased address is the same account');
self::assertSame(['email' => '[email protected]'], $lookupCriteria);
self::assertSame(
'[email protected]',
$user->getEmail(),
'the stored casing follows the login rather than staying frozen',
);
}
public function testApprovedRoleSurvivesTheNextLogin(): void
{
$existing = (new User('[email protected]'))->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);
+1 -1
View File
@@ -13,7 +13,7 @@ class AppRuntimeRoleLabelsTest extends TestCase
public function testEffectiveRolesAreLabelled(): void
{
self::assertSame(
['Teamer:in', 'Preisrechner'],
['Teamer:in', 'GRO-Expert:in'],
$this->runtime()->effectiveRoles([Role::TEAMER, Role::GROUPS_MANAGER]),
);
}